From 99a558ddd963a6b74a9425b6f0520756fc5cfab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 24 Sep 2026 15:28:04 -0300 Subject: [PATCH] feat: redesign the Kanban and keep test wording out of production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kanban: tabs for production, quote review and payments/integrations; compact cards with products, metres, print-file status, delivery and time in stage; an order panel with stage progress, one main action, a correction reason in place, items with a preview drawn from the approved layout, final-file approval and the history as a timeline. Quote review gets a list and a pane; payment issues resolve in place; integrations show their real state, Tiny's connection with a read-only "Testar conexão", and a readable send log. The previous Kanban is kept in git tag ui-v1 and is no longer served. Production wording: the customer portal no longer says it is a local test environment outside the local stack; the checkout no longer tells customers to use the Kanban or shows internal stage codes; sign-in, session, quota and print-file messages are Portuguese and never say "local". A simulated freight price is refused outside the local stack until a real freight provider exists, so production only offers pickup. The browser suite drives the new tabs and panel and still checks the whole upload, quote, payment and production journey. Full CI sequence passes locally. Co-Authored-By: Claude Opus 5.5 --- app/adapters.py | 2 + app/api/customer.py | 2 +- app/api/health.py | 4 +- app/api/operator.py | 21 +- app/api/quotes.py | 3 +- app/api/uploads.py | 2 +- app/core/auth.py | 10 +- app/printfile.py | 39 +-- app/printjobs.py | 4 +- app/runtime.py | 7 + app/tiny.py | 19 ++ docs/LOCAL_SETUP.md | 37 ++- tests/browser_test.mjs | 17 +- tests/print_file_test.py | 2 +- tests/test_printfile.py | 14 +- tests/test_tiny.py | 13 +- web/checkout.js | 7 +- web/kanban.css | 182 +++++++++++ web/kanban.html | 81 +++-- web/kanban.js | 679 +++++++++++++++++++++++++++------------ web/portal.html | 4 +- web/portal.js | 9 +- 22 files changed, 861 insertions(+), 297 deletions(-) create mode 100644 web/kanban.css diff --git a/app/adapters.py b/app/adapters.py index 4cac37a..19172e7 100644 --- a/app/adapters.py +++ b/app/adapters.py @@ -146,6 +146,8 @@ class FreightAdapter(Protocol): def quote(self, service: str, postal_code: str) -> dict: ... class FakeFreight: + name = 'fake' + def quote(self, service: str, postal_code: str) -> dict: if service == 'pickup': return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} diff --git a/app/api/customer.py b/app/api/customer.py index 2a4c4d5..ff5704b 100644 --- a/app/api/customer.py +++ b/app/api/customer.py @@ -57,7 +57,7 @@ def login(body: Login, request: Request, response: Response): if not account or not matches: login_failed(email) audit('customer_login_failed', ip=client_ip(request)) - raise HTTPException(401, 'Invalid email or password') + raise HTTPException(401, 'E-mail ou senha inválidos.') previous = current(request) with db.connect() as c: if not stored.startswith('scrypt-v2$'): diff --git a/app/api/health.py b/app/api/health.py index 30c0320..76b7a60 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -7,7 +7,8 @@ from ..core import db from ..core.auth import client_ip, owner, new_session, rate_limit from ..core.limits import upload_limit_bytes from ..core.models import Freight -from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage +from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, + require_delivery_available, storage) router = APIRouter() @@ -40,6 +41,7 @@ def session(request: Request, response: Response): @router.post('/api/freight') def quote_freight(body: Freight): + require_delivery_available(body.service) try: return freight.quote(body.service, body.postal_code) except ValueError as exc: diff --git a/app/api/operator.py b/app/api/operator.py index 433d6d4..c6a46cf 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review from ..core.pricing import price from ..printjobs import queue as queue_print_files from .. import tiny -from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS, +from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment, enqueue, freight, quote_view, storage, upload_row) from ..scanning import require_clean @@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response): with db.connect() as c: account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone() if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone(): - raise HTTPException(503, 'No Kanban operator account is configured') + raise HTTPException(503, 'Nenhuma conta de operador configurada.') # Comparable password work whether or not the account exists or is active. stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH matches = password_matches(body.password, stored) if not account or not account['active'] or not matches: login_failed('operator:'+email) audit('operator_login_failed', ip=client_ip(request), operator=email) - raise HTTPException(401, 'Invalid operator login') + raise HTTPException(401, 'E-mail ou senha inválidos.') token = secrets.token_urlsafe(32) with db.connect() as c: previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest() @@ -97,7 +97,8 @@ def board(user=Depends(operator)): FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL ORDER BY received_at LIMIT 100''').fetchall() return {'states': STATES, 'transitions': TRANSITIONS, - 'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), + 'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), 'operator': user, + 'providers': {'payment': payment.name}, 'environment': ENVIRONMENT, 'finished_shown': len(finished), 'finished_total': finished_total, 'quotes': [quote_view(c, q) for q in pending + approved], 'pending_total': pending_total, 'approved_total': approved_total, @@ -224,6 +225,18 @@ def tiny_connect(user=Depends(operator)): audit('tiny_connect_started', operator=user) return {'url': tiny.TinyAuth().authorize_url(user)} +@router.post('/api/operator/tiny/test') +def tiny_test(user=Depends(operator)): + """Read one order and one contact from Tiny. Creates nothing.""" + if not tiny.configured(): + raise HTTPException(503, 'Tiny application is not configured') + try: + results = tiny.check() + except tiny.TinyNotConnected as exc: + raise HTTPException(409, str(exc)) + audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values())) + return {'ok': all(v == 'ok' for v in results.values()), 'results': results} + @router.get('/api/operator/tiny/callback') def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)): """Tiny's redirect back. Cross-site, so the operator cookie is absent: the diff --git a/app/api/quotes.py b/app/api/quotes.py index dcf38f4..b79770c 100644 --- a/app/api/quotes.py +++ b/app/api/quotes.py @@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb from ..core import db from ..core.auth import owner from ..core.models import QuoteRequest -from ..runtime import freight, quote_view, upload_row +from ..runtime import freight, quote_view, require_delivery_available, upload_row from ..scanning import require_clean router = APIRouter() @@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)): for item in body.items: if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'): raise HTTPException(422, 'Quoted metres do not match the submitted layout height') + require_delivery_available(body.freight.service) draft = body.model_dump(mode='json', exclude={'request_key'}) digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() try: diff --git a/app/api/uploads.py b/app/api/uploads.py index 5d95428..cf2ef26 100644 --- a/app/api/uploads.py +++ b/app/api/uploads.py @@ -37,7 +37,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)): usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))): audit('upload_quota_rejected') - raise HTTPException(429, 'Local storage quota or pending upload limit reached') + raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.') multipart = storage.begin(key) c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at) VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''', diff --git a/app/core/auth.py b/app/core/auth.py index f049ca4..1fc9cf6 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -45,7 +45,7 @@ def session_row(request): try: sid = UUID(request.cookies.get('dtf_session', '')) except ValueError: - raise HTTPException(401, 'Start a local session first') + raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.') with connect() as c: row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone() if not row: @@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900): RETURNING attempts""", (key,seconds,seconds)).fetchone() if row['attempts'] > limit: audit('rate_limit', scope=scope) - raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)}) + raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)}) ACCOUNT_FAILURES = 10 @@ -108,7 +108,7 @@ def throttle(email, request): WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone() if row and row['attempts'] >= ACCOUNT_FAILURES: audit('rate_limit', scope='auth-account') - raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'}) + raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'}) def login_failed(email): """Count a failed sign-in (or registration attempt) against the account.""" @@ -117,10 +117,10 @@ def login_failed(email): def operator(request: Request): token = request.cookies.get('dtf_operator', '') if not token or len(token)>128: - raise HTTPException(401, 'Sign in to the local Kanban') + raise HTTPException(401, 'Entre no Kanban.') digest = hashlib.sha256(token.encode()).hexdigest() with connect() as c: row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone() if not row: - raise HTTPException(401, 'Operator session expired') + raise HTTPException(401, 'Sua sessão expirou. Entre de novo.') return row['username'] diff --git a/app/printfile.py b/app/printfile.py index 5f3484c..398fd90 100644 --- a/app/printfile.py +++ b/app/printfile.py @@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None class Unsupported(Exception): - """This item cannot be generated automatically; the reason is for the operator.""" + """This item cannot be generated automatically. The reason is shown to the + operator on the Kanban, so it is written in Portuguese.""" class Name(str): @@ -153,14 +154,14 @@ class SourceImage: self.image = Image.open(path) self.format = self.image.format except Image.DecompressionBombError as exc: - raise Unsupported(f'"{name}" is too large to generate automatically') from exc + raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc except Exception as exc: - raise Unsupported(f'"{name}" is not an image this generator can read') from exc + raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc if self.format not in SUPPORTED_FORMATS: - raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; ' - 'only JPEG, PNG, WebP and TIFF are generated automatically') + raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; ' + 'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente') if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF': - raise Unsupported(f'"{name}" is animated or has several frames') + raise Unsupported(f'"{name}" é animado ou tem vários quadros') # Browsers draw a photo upright according to its EXIF orientation, and # the Site measured it that way, so the print must too. try: @@ -181,8 +182,8 @@ class SourceImage: return self._embed_jpeg(pdf) width, height = self.image.size if width * height > MAX_DECODED_PIXELS: - raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the ' - 'generator decodes; prepare this item by hand') + raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o ' + 'gerador processa; prepare este item à mão') return self._embed_pixels(pdf) def _colorspace(self, pdf, mode): @@ -235,7 +236,7 @@ class SourceImage: elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'): color_mode = 'RGB' else: - raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically') + raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente') if has_alpha: image = image.convert('RGBA' if color_mode == 'RGB' else 'LA') width, height = image.size @@ -304,29 +305,29 @@ class PdfPage: try: self.pdf = pikepdf.open(path) except pikepdf.PasswordError as exc: - raise Unsupported(f'"{name}" is password-protected') from exc + raise Unsupported(f'"{name}" está protegido por senha') from exc except Exception as exc: - raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc + raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc try: pages = len(self.pdf.pages) if pages != 1: - raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically') + raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente') self.page = self.pdf.pages[0] self.rotation = int(self.page.rotation) % 360 if self.rotation not in (0, 90, 180, 270): - raise Unsupported(f'"{name}" has an unsupported page rotation') + raise Unsupported(f'"{name}" tem uma rotação de página não suportada') box = [float(v) for v in self.page.cropbox] except Unsupported: self.pdf.close() raise except Exception as exc: self.pdf.close() - raise Unsupported(f'"{name}" has a page this generator cannot read') from exc + raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3]) self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1]) if self.w <= 0 or self.h <= 0: self.pdf.close() - raise Unsupported(f'"{name}" has an empty page') + raise Unsupported(f'"{name}" tem uma página vazia') # As displayed, which is what the proportions are checked against. self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h) @@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()): height = Decimal(str(production['height_cm'])) billed = Decimal(str(item['billed_metres'])) * 100 if height > billed + HEIGHT_TOLERANCE_CM: - raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed') + raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados') lowest = None for placement in production['placements']: width_px, height_px = sizes[placement['source_index']] @@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()): drift = abs((width_px / height_px) / (width_cm / length_cm) - 1) if drift > ASPECT_TOLERANCE: source = production['sources'][placement['source_index']] - raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match ' - f'the quoted {source["width_cm"]} x {source["length_cm"]} cm') + raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes ' + f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)') if placement['source_index'] in vector: continue dpi = width_px / (width_cm / 2.54) @@ -405,7 +406,7 @@ def render(item, files, out, title): """ production = item['production'] if production.get('version') != 2: - raise Unsupported('item uses an obsolete production layout') + raise Unsupported('o item usa uma montagem antiga') sources = [] try: for index in range(len(production['sources'])): diff --git a/app/printjobs.py b/app/printjobs.py index 9dfd6e5..03667d8 100644 --- a/app/printjobs.py +++ b/app/printjobs.py @@ -101,9 +101,9 @@ def produce(storage, job, item, uploads): for upload_id in item['uploads']: row = uploads.get(upload_id) if not row or row['scan_state'] != 'clean': - raise Unsupported('an original file is missing or not cleared by the malware scan') + raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus') if row['purged_at'] or row['expired']: - raise Unsupported('an original file has passed its retention period') + raise Unsupported('um arquivo original passou do prazo de guarda') number = job['number'] name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf' with tempfile.TemporaryDirectory(prefix='print-') as scratch: diff --git a/app/runtime.py b/app/runtime.py index f6db7b3..591c86b 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -48,6 +48,13 @@ TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'], 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []} +def require_delivery_available(service): + """Outside the local stack, a simulated freight price must never reach a + customer: until a real freight provider exists, only pickup is offered.""" + if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake': + raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.') + + def upload_row(c, upload_id, session_id, lock=False): row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' + (' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone() diff --git a/app/tiny.py b/app/tiny.py index 1547c86..b8f6dea 100644 --- a/app/tiny.py +++ b/app/tiny.py @@ -223,6 +223,25 @@ def order_payload(payload, contact_id, today=None): return pedido +def check(auth=None, transport=None): + """Read-only proof that the connection and permissions work: one order and + one contact listed, nothing created. Raises TinyNotConnected when there is + no usable connection; otherwise reports each read separately.""" + orders = TinyOrders(auth=auth or TinyAuth(), transport=transport) + results = {} + for name, path in (('pedidos', '/pedidos'), ('contatos', '/contatos')): + try: + orders.request('GET', path, params={'limit': 1}) + results[name] = 'ok' + except TinyNotConnected: + raise + except TinyError as exc: + results[name] = str(exc)[:200] + except httpx.HTTPError: + results[name] = 'sem resposta do Tiny' + return results + + class TinyOrders: def __init__(self, auth=None, transport=None, today=None): missing = [name for name in required_settings() if not os.environ.get(name)] diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md index 07bfa45..8f4ddaa 100644 --- a/docs/LOCAL_SETUP.md +++ b/docs/LOCAL_SETUP.md @@ -62,25 +62,30 @@ operator interfaces. 4. Click the Site checkout button. Files upload directly to MinIO, remain quarantined until ClamAV returns `clean`, and then become eligible for a quote. The local banner displays a quote ID awaiting commercial review. -5. Open Kanban and log in. In **Cotações**, download the original if needed, - confirm/correct total metres and grade, tick the manual confirmation, and - click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0. +5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download + the original if needed, confirm/correct metres and grade, tick the + confirmation at the bottom and click **Aprovar cotação**. For the fixture + keep 1.01 m and grade 0. 6. Return to the Site and click its checkout action again. Inspect the authoritative server total, then click **Criar pedido de teste**. No real payment occurs. -7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using - the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**, - select the manually prepared final files for every item, enter a review note, - tick the confirmation and click **Aprovar arquivos finais**. Use the harmless - fixture again only for this local test. The text fixture is not an image, so - its print file shows **preparar à mão**; with a PNG or JPEG artwork the - generated PDF is preselected instead (see "Print files"). - Continue through **Fila de impressão → - Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can - return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally. -8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts - should say recorded locally. Download links expire after five minutes; - request another link from Kanban when needed. +7. Click **Atualizar** on the Kanban. The paid order appears in **Arte + recebida** on the **Produção** tab. Click its card to open the order panel, + then **Mover para Arte tratada** (dragging the card to a column also works). + In the panel's **Arquivos finais**, select the manually prepared final file + for every item, enter a review note, tick the confirmation and click + **Aprovar arquivos finais**. Use the harmless fixture again only for this + local test. The text fixture is not an image, so its print file shows + **Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is + preselected instead (see "Print files"). Continue with the panel's main + button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir + correção** asks for the reason the customer will see; from **Correção** the + order returns to **Arte recebida** or **Arte tratada**. +8. The panel's **Histórico** lists every move. The **Pagamentos e + integrações** tab shows the Tiny connection, payments that need a person, + and the send log; with the fake adapters every send reads "Registrado + (simulado)". Download links expire after five minutes; click again for a new + one. The previous Kanban is kept in git tag `ui-v1`. The manual quote step is necessary to enforce the backend trust boundary while automatic pre-flight is deferred. Browser measurements and grade are proposals. diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index 85f8991..004ab27 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -92,6 +92,11 @@ try{ await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); await kanban.eval('document.getElementById("login").requestSubmit()'); + // Quotes live in their own tab; the review pane shows the one picked. + await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in'); + await kanban.click('[data-tab="quotes"]'); + await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban'); + await kanban.click(`[data-quote-pick="${qid}"]`); await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban'); assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null); assert.equal(await kanban.eval('document.getElementById("password").value'),''); @@ -114,19 +119,23 @@ try{ const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`); assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`), {kind:'sheet',copies:1,length:101,height:101,placed:1}); - // Click real transition buttons, including rerender after each move. + // Open the order's panel from its card, then click the real transition + // buttons there, including the rerender after each move. + await kanban.click('[data-tab="board"]'); + await kanban.click(`[data-card="${oid}"]`); + await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel'); for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){ if(state==='fil'){ - await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`); await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls'); const doc=await kanban.call('DOM.getDocument'); const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`}); await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]}); await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file'); - await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`); + await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`); await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval'); } - await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`); + await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title); + await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`); await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state); } // Reload proves the board is persisted on the backend. diff --git a/tests/print_file_test.py b/tests/print_file_test.py index 9b6a82a..213d76c 100644 --- a/tests/print_file_test.py +++ b/tests/print_file_test.py @@ -126,7 +126,7 @@ def run(): manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr') order = paid_order(client, loose_item(manual, copies=1)) order, row = wait_print(client, order['id'], 'manual') - assert 'not an image' in row['detail']['reason'], row + assert 'não é uma imagem' in row['detail']['reason'], row rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True) assert rows[0]['status'] == 'pending' wait_print(client, order['id'], 'manual') diff --git a/tests/test_printfile.py b/tests/test_printfile.py index aed5b19..1e7b2e9 100644 --- a/tests/test_printfile.py +++ b/tests/test_printfile.py @@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase): def test_refuses_what_it_cannot_reproduce(self): png = self.save(quadrants(), 'a.png') - with self.assertRaisesRegex(Unsupported, 'proportions'): + with self.assertRaisesRegex(Unsupported, 'proporções'): self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png]) - with self.assertRaisesRegex(Unsupported, 'billed'): + with self.assertRaisesRegex(Unsupported, 'cobrados'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png]) fake_pdf = os.path.join(self.dir.name, 'art.pdf') with open(fake_pdf, 'wb') as handle: handle.write(b'%PDF-1.4\n%%EOF\n') - with self.assertRaisesRegex(Unsupported, 'not a PDF'): + with self.assertRaisesRegex(Unsupported, 'não é um PDF'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf]) cdr = os.path.join(self.dir.name, 'art.cdr') with open(cdr, 'wb') as handle: handle.write(b'not artwork') - with self.assertRaisesRegex(Unsupported, 'not an image'): + with self.assertRaisesRegex(Unsupported, 'não é uma imagem'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr]) def test_long_layouts_scale_user_space_instead_of_splitting(self): @@ -247,12 +247,12 @@ class PdfSourceTests(unittest.TestCase): self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54))) def test_refuses_pdfs_it_cannot_place(self): - with self.assertRaisesRegex(Unsupported, '2 pages'): + with self.assertRaisesRegex(Unsupported, '2 páginas'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)]) - with self.assertRaisesRegex(Unsupported, 'password'): + with self.assertRaisesRegex(Unsupported, 'senha'): self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('locked.pdf', password='secret')]) - with self.assertRaisesRegex(Unsupported, 'proportions'): + with self.assertRaisesRegex(Unsupported, 'proporções'): self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')]) @unittest.skipIf(fitz is None, 'PyMuPDF is not installed') diff --git a/tests/test_tiny.py b/tests/test_tiny.py index 9e5b55d..f261f73 100644 --- a/tests/test_tiny.py +++ b/tests/test_tiny.py @@ -43,8 +43,8 @@ class TinyTests(unittest.TestCase): self.calls.append((request.method, path)) assert request.headers['authorization'] == 'Bearer access-1' if request.method == 'GET' and path == '/contatos': - cnpj = request.url.params['cpfCnpj'] - return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]}) + cnpj = request.url.params.get('cpfCnpj') + return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]}) if request.method == 'POST' and path == '/contatos': contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)} self.contacts.append(contact) @@ -61,6 +61,7 @@ class TinyTests(unittest.TestCase): return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']}) return httpx.Response(404) + self.handler = handler self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler), today=date(2026, 9, 24)) @@ -106,6 +107,14 @@ class TinyTests(unittest.TestCase): self.tiny.deliver('k3', PAID) self.assertNotIn(('POST', '/pedidos'), self.calls) + def test_connection_check_only_reads(self): + from app.tiny import check + results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler)) + self.assertEqual(results, {'pedidos': 'ok', 'contatos': 'ok'}) + self.assertTrue(all(method == 'GET' for method, _ in self.calls)) + denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden'))) + self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403')) + def test_rate_limit_is_a_retryable_failure(self): tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429))) with self.assertRaisesRegex(TinyError, 'rate limit'): diff --git a/web/checkout.js b/web/checkout.js index 7bb5bbc..622d5b6 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -127,7 +127,9 @@ return; } if (quote.status==='pending_review') { - message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'); + message((await ready).environment==='local' + ? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.' + : 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.'); } else if (quote.status==='approved') { message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.'); if ((await ready).payment_provider === 'mercadopago') { @@ -163,7 +165,8 @@ } catch(error) { message(error.message); event.target.disabled=false; } }); } else if (quote.status==='paid') { - message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.'); + const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'}; + message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.'); button('Novo pedido',()=>{clearDraft();location.reload();}); } else { message('Cotação expirada. Envie o carrinho para uma nova revisão.'); diff --git a/web/kanban.css b/web/kanban.css new file mode 100644 index 0000000..265e344 --- /dev/null +++ b/web/kanban.css @@ -0,0 +1,182 @@ +/* Kanban DTF. The previous look is kept in git tag ui-v1. */ +:root{ + --bg:#0F1113;--bar:#13161A;--col:#13161A;--card:#1A1D21;--card2:#23282D;--panel:#15181B; + --line:#262A2F;--line2:#2A2F35;--line3:#3A4047; + --tx:#ECE9E4;--tx2:#D5D2CC;--muted:#A3A9B0;--faint:#8B929A; + --accent:#FF8B2B;--accent-tx:#FFB575;--ok:#6FD19C;--warn:#F2B33D;--bad:#F4968B;--bad-line:#5A3530; + --rec:#2BB6D9;--tra:#E0508F;--fil:#E9C23A;--imp:#D9D6D0;--cor:#F06A5B;--fin:#3FB97A; + --mono:Manrope,system-ui,sans-serif; +} +*{box-sizing:border-box} +html,body{margin:0;background:var(--bg);color:var(--tx)} +body{font:14px/1.45 Manrope,system-ui,sans-serif;font-variant-numeric:tabular-nums} +button,input,textarea{font:inherit;color:inherit} +button{cursor:pointer} +button:disabled{opacity:.5;cursor:default} +a{color:var(--accent-tx)} +[hidden]{display:none!important} +.mono{font-family:var(--mono)} +.muted{color:var(--muted)} +.faint{color:var(--faint)} +.btn{height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:var(--card2);color:var(--tx);font-size:13px;font-weight:600;white-space:nowrap} +.btn:hover{border-color:var(--line3)} +.btn.ghost{background:transparent;color:var(--muted)} +.btn.primary{height:44px;border:0;background:var(--accent);color:#0F1113;font-weight:800;font-size:14px;padding:0 18px} +.btn.primary:hover{filter:brightness(1.08)} +.btn.danger{height:44px;background:transparent;border-color:var(--bad-line);color:var(--bad);font-weight:700;font-size:14px} +.btn.warn{border:0;background:var(--warn);color:#0F1113;font-weight:800} +.chip{height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);font-size:13px;font-weight:600} +.chip[aria-pressed=true]{border-color:var(--accent);background:rgba(255,139,43,.12);color:var(--accent-tx)} +.badge{font-size:12px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--accent);color:#0F1113} +.badge.warn{background:var(--warn);color:#0F1113} +.badge:empty{display:none} +.eyebrow{font-size:12px;color:var(--faint);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 8px} +input[type=text],input[type=email],input[type=password],input[type=number],input:not([type]),textarea{height:40px;padding:0 12px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)} +textarea{height:auto;padding:10px 12px;min-height:72px;width:100%;resize:vertical} +input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent)} +input[type=file]{font-size:13px;color:var(--muted);max-width:100%} +.check{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--tx2)} +.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)} + +/* Sign-in */ +.login{max-width:380px;margin:12vh auto 0;padding:32px;border:1px solid var(--line2);border-radius:14px;background:var(--panel);display:flex;flex-direction:column;gap:14px} +.login label{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600;color:var(--muted)} +.login .hint{font-size:12px;color:var(--faint);margin:0} + +/* Top bar */ +.top{height:64px;padding:0 24px;display:flex;align-items:center;gap:22px;border-bottom:1px solid var(--line);background:var(--bar);position:sticky;top:0;z-index:5} +.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;letter-spacing:-.01em;white-space:nowrap} +.brand i{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#0F1113;font-style:normal;font-weight:800;font-size:15px;display:flex;align-items:center;justify-content:center} +.tabs{display:flex;gap:4px;align-self:stretch} +.tabs button{display:flex;align-items:center;gap:8px;padding:0 14px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-weight:600;font-size:14px} +.tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)} +.tabs .count{font-family:var(--mono);font-size:12px;color:var(--muted)} +.search{display:flex;align-items:center;gap:8px;width:300px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint);margin-left:auto} +.search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none} +.tinydot{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);white-space:nowrap} +.tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)} +.tinydot:empty{display:none} +.who{font-size:13px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis} +#status{margin:0;padding:8px 24px 0;min-height:28px;font-size:13px;color:var(--muted)} +#status.error,#login-status.error{color:var(--bad)} + +/* Board */ +main{padding:0 24px 24px} +.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 0 12px} +.bar h1,.split h1,.page h1{margin:0;font-size:22px;font-weight:800;letter-spacing:-.02em} +.chips{display:flex;gap:8px;flex-wrap:wrap} +.bar .chips{margin-left:auto} +.kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start} +.col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px} +.col.target{border-color:var(--accent)} +.col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B} +.col-head div{display:flex;align-items:center;gap:8px} +.col-head h2{margin:0;font-size:14px;font-weight:700} +.col-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--c)} +.col-head .n{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--muted)} +.col-head small{font-size:12px;color:var(--faint)} +.col .empty{font-size:12px;color:var(--faint);padding:8px 2px} +.card{text-align:left;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%} +.card:hover,.card.open{border-color:var(--line3);background:#1E2226} +.card .row{display:flex;align-items:baseline;gap:8px;width:100%} +.card .num{font-size:14px;font-weight:800} +.card .age{margin-left:auto;font-size:12px;color:var(--faint)} +.card .cust{font-size:13px;font-weight:600;color:var(--tx);width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tags{display:flex;flex-wrap:wrap;gap:6px} +.tag{font-size:12px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)} +.card .foot{display:flex;align-items:center;gap:6px;font-size:12px} +.card .foot .where{margin-left:auto;color:var(--muted)} +.late{font-size:12px;color:#F06A5B;font-weight:600} + +/* Order panel */ +.drawer{position:fixed;inset:0;z-index:10;display:flex;justify-content:flex-end} +.scrim{position:absolute;inset:0;background:rgba(8,9,11,.6)} +.panel{position:relative;width:min(600px,100vw);height:100%;background:var(--panel);border-left:1px solid var(--line2);display:flex;flex-direction:column} +.panel-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:14px} +.panel-head .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap} +.panel-head .num{font-size:22px;font-weight:800} +.stage{font-size:12px;font-weight:700;padding:4px 10px;border-radius:999px;background:color-mix(in srgb,var(--c) 18%,transparent);color:var(--c)} +.close{margin-left:auto;width:40px;height:40px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center} +.steps{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px} +.steps li{display:flex;flex-direction:column;gap:6px;font-size:11px;color:var(--faint)} +.steps li::before{content:"";height:4px;border-radius:4px;background:var(--line2)} +.steps li.done{color:var(--muted)}.steps li.done::before{background:var(--c)} +.steps li.now{color:var(--tx);font-weight:700}.steps li.now::before{background:var(--c)} +.moves{display:flex;gap:10px;flex-wrap:wrap} +.moves .primary{flex:1} +.reason{display:flex;flex-direction:column;gap:8px} +.panel-body{flex:1;overflow:auto;padding:20px 24px 32px;display:flex;flex-direction:column;gap:24px} +.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px} +.facts b{display:block;font-size:14px} +.facts span{font-size:13px;color:var(--muted);overflow-wrap:anywhere} +.item{display:flex;gap:14px;padding:14px;border:1px solid var(--line2);border-radius:12px;background:var(--card)} +.item.attention{border-color:#4A3A1E;background:#1C1A15} +.item .preview{width:96px;flex-shrink:0;border-radius:8px;background:var(--card2);display:flex;align-items:flex-start;justify-content:center;padding:6px} +.item .preview svg{width:100%;height:auto;display:block} +.item .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px} +.item .title{display:flex;align-items:baseline;gap:8px} +.item .title b{font-size:14px} +.item .title .mono{margin-left:auto;font-size:13px} +.item .line{font-size:12px;color:var(--muted)} +.item .file{display:flex;align-items:center;gap:8px;font-size:13px} +.buttons{display:flex;gap:8px;flex-wrap:wrap} +.finals{display:flex;flex-direction:column;gap:12px} +.finals .rev{font-size:13px;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--line2);border-radius:10px} +.finals form{display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--line2);border-radius:12px} +.finals .slot{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600} +.timeline{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:10px} +.timeline li{display:flex;gap:12px;font-size:13px} +.timeline li::before{content:"";width:8px;height:8px;margin-top:6px;border-radius:999px;background:var(--c,var(--muted));flex-shrink:0} +.timeline small{display:block;color:var(--faint);font-size:12px} + +/* Quotes */ +.split{display:flex;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:calc(100vh - 140px)} +.split .list{width:460px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;display:flex;flex-direction:column;gap:12px;background:#111316} +.qitem{text-align:left;display:flex;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%} +.qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15} +.qitem b{display:block;font-size:14px;overflow-wrap:anywhere} +.qitem span{font-size:12px;color:var(--muted)} +.qitem .right{text-align:right;flex-shrink:0} +.qitem .flag{display:block;color:var(--warn);font-weight:600;margin-top:4px} +.pane{flex:1;min-width:0;display:flex;flex-direction:column} +.pane .head{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap} +.pane .head b{font-size:18px;overflow-wrap:anywhere} +.pane .content{flex:1;padding:20px 24px;display:flex;flex-direction:column;gap:16px} +.pane .fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px} +.pane .fields label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)} +.pane .fields input[readonly]{color:var(--faint);background:var(--panel);border-color:var(--line2)} +.pane .foot{padding:16px 24px;border-top:1px solid var(--line);background:var(--bar);display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.pane .foot .total{margin-left:auto;font-family:var(--mono);color:var(--muted)} +.pane .foot .total b{color:var(--tx)} +.pane .note{padding:24px;color:var(--muted)} + +/* Payments and integrations */ +.page{display:flex;flex-direction:column;gap:22px;padding-top:8px} +.page h2{margin:0 0 10px;font-size:15px;font-weight:800} +.issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px} +.issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px} +.issue .text b{font-size:14px} +.issue .text span{font-size:13px;color:var(--muted);overflow-wrap:anywhere} +.issue form{display:flex;gap:8px;align-items:center} +.issue input{width:320px} +.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} +.integration{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--line2);background:var(--card)} +.integration .head{display:flex;align-items:center;gap:8px} +.integration .head b{font-size:15px} +.integration .head .dot{width:9px;height:9px;border-radius:999px;background:var(--dot,#5B6168)} +.integration .head em{margin-left:auto;font-style:normal;font-size:12px;font-weight:700;color:var(--dotx,var(--muted))} +.integration p{margin:0;font-size:13px;color:var(--muted)} +.integration .buttons{margin-top:auto} +table.log{width:100%;border-collapse:collapse;font-size:13px} +table.log th{text-align:left;color:var(--faint);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--line)} +table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:top} +.good{color:var(--ok);font-weight:600}.retry{color:var(--warn);font-weight:600} + +@media (max-width:900px){ + .top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px} + .search{order:5;width:100%;margin-left:0} + main{padding:0 16px 16px} + .split{flex-direction:column} + .split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)} + .facts,.pane .fields{grid-template-columns:1fr} +} diff --git a/web/kanban.html b/web/kanban.html index 49466b0..5b52fa1 100644 --- a/web/kanban.html +++ b/web/kanban.html @@ -1,21 +1,64 @@ -DTF · Kanban local - -

Kanban DTF

-
Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.
-
-

-
-
Eventos locais de integração
- +Kanban DTF + + + + + + + + + + diff --git a/web/kanban.js b/web/kanban.js index 361e867..a58e09b 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -1,249 +1,516 @@ -/* Reuses the prototype palette, column names and drag/drop interaction; no machine controls. */ +/* Kanban DTF: production board, quote review, payments and integrations. + Everything is built with textContent; nothing from an order or a customer is + ever parsed as HTML. The previous screen is in git tag ui-v1. */ const $ = id=>document.getElementById(id); // Remove credentials saved by older local builds. Only HttpOnly sessions now. sessionStorage.removeItem('dtf-operator'); -let board; -let extraQuotes={pending:[],approved:[]}; -let moreQuotes={pending:false,approved:false}; + +const STAGE_COLORS={rec:'var(--rec)',tra:'var(--tra)',fil:'var(--fil)',imp:'var(--imp)',cor:'var(--cor)',fin:'var(--fin)'}; +const FLOW=['rec','tra','fil','imp','fin']; +const PRODUCT={file:'DTF Têxtil · folha montada',avulsa:'DTF Têxtil · artes avulsas',uvfile:'DTF UV · folha montada',uv:'DTF UV · artes avulsas'}; +const SHORT={file:'Têxtil folha',avulsa:'Têxtil avulsa',uvfile:'UV folha',uv:'UV avulsa'}; +const EVENTS={payment_approved:'Pagamento aprovado',production_started:'Produção iniciada',correction_needed:'Correção necessária',ready:'Pedido pronto'}; +const LATE_HOURS=4; +// Payment outcomes are recorded by the API in English; operators read Portuguese. +function outcomeText(outcome){ + const rules=[ + [/^refused: paid (\S+) but quote total is (\d+)/,m=>'valor pago ('+(m[1]==='None'?'não informado':money(Number(m[1])))+') diferente do total da cotação ('+money(Number(m[2]))+')'], + [/^refused: quote expired/,()=>'a cotação venceu antes do pagamento'], + [/^refused: quote not found/,()=>'cotação não encontrada'], + [/^refused: reference is not a quote id/,()=>'a referência do pagamento não é uma cotação'], + [/^refused: quote was never reviewed/,()=>'a cotação ainda não tinha sido revisada'], + [/^refused: quote uses an obsolete/,()=>'a cotação usa uma montagem antiga'], + [/^attention: payment (\w+) for order (\d+)/,m=>'pagamento '+(m[1]==='refunded'?'estornado':'cancelado')+' depois do pedido #'+m[2]], + ]; + for(const [pattern,text] of rules){const m=outcome.match(pattern);if(m)return text(m);} + return outcome; +} + +let board=null; +let tab='board', filter='all', search='', openOrder=null, openToken=0; +let quoteKind='pending', pickedQuote=null; +let extraQuotes={pending:[],approved:[]}, moreQuotes={pending:false,approved:false}; + const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); -function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined)e.textContent=text;if(className)e.className=className;return e;} -function action(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('status').textContent=e.message;}finally{b.disabled=false;}};return b;} +const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m'; +const when=value=>new Date(value).toLocaleString('pt-BR',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}); +function ago(value){ + const minutes=Math.max(0,Math.round((Date.now()-new Date(value))/60000)); + if(minutes<60)return 'há '+minutes+' min'; + const hours=Math.round(minutes/60); + return hours<48?'há '+hours+' h':'há '+Math.round(hours/24)+' dias'; +} +function node(tag,text,className){const e=document.createElement(tag);if(text!==undefined&&text!==null)e.textContent=text;if(className)e.className=className;return e;} +function button(text,fn,className='btn'){ + const b=node('button',text,className);b.type='button'; + b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}}; + return b; +} +function say(text,error=false){ + for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);} +} +function icon(path){ + const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg'); + svg.setAttribute('width','14');svg.setAttribute('height','14');svg.setAttribute('viewBox','0 0 24 24'); + svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','2.2');svg.setAttribute('aria-hidden','true'); + for(const d of path){const p=document.createElementNS(ns,d.startsWith('circle')?'circle':'path'); + if(d.startsWith('circle')){const [cx,cy,r]=d.split(' ').slice(1);p.setAttribute('cx',cx);p.setAttribute('cy',cy);p.setAttribute('r',r);}else p.setAttribute('d',d);svg.append(p);} + return svg; +} +const ICON_OK=['M5 12l4 4 10-10'], ICON_WARN=['M12 8v5M12 17h.01','circle 12 12 9']; async function api(path,body){ const r=await fetch('/api/operator'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})}); const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos ('+r.status+').');error.status=r.status;throw error;}return d; } -function files(container,items){ - for(const uid of [...new Set(items.flatMap(i=>i.uploads))])container.append(action('Baixar original '+uid.slice(0,6),async()=>{ - const result=await api('/uploads/'+uid+'/download'); - const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click(); - })); -} -function productionLines(container,item){ - if(!item.production){container.append(node('p','Instruções de produção ausentes; não produzir este item.','meta'));return;} - container.append(node('p','Especificação v'+item.production.version+' · qualidade '+item.quality_status+ - (item.quality_status==='warning'?' · ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):''),'meta')); - if(item.production.placements){ - container.append(node('p',item.production.placements.length+' peças posicionadas em '+ - item.production.film_width_cm+' × '+item.production.height_cm+' cm de filme','meta')); - const download=node('button','Baixar manifesto da montagem'); - download.type='button'; - download.onclick=()=>{ - const url=URL.createObjectURL(new Blob([JSON.stringify(item.production,null,2)],{type:'application/json'})); - const link=node('a');link.href=url; - link.download='dtf-layout-'+item.production.sources[0].upload_id.slice(0,8)+'.json'; - link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); - }; - container.append(download); - } - item.production.sources.forEach((source,index)=>container.append(node('p', - (index+1)+'. '+source.kind+' · '+source.copies+' × '+source.width_cm+' × '+source.length_cm+ - ' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+ - ' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta'))); -} -const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto', - manual:'preparar à mão',failed:'falhou ao gerar'}; function download(uid){return async()=>{ const result=await api('/uploads/'+uid+'/download'); const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click(); };} -function printFiles(card,order){ - const rows=order.print_files||[]; - const box=node('div',undefined,'print'); - box.append(node('b','Arquivo de impressão')); - order.snapshot.items.forEach((item,index)=>{ - const row=rows.find(r=>r.item_index===index); - const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta'); - if(row?.status==='ready'){ - line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+ - (row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'')+ - (row.detail.vector_sources?' · PDF vetorial':''); - line.append(action('Baixar PDF',download(row.upload_id))); - } - if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason)); - box.append(line); - }); - const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index); - return !row||row.status==='manual'||row.status==='failed';}); - if(retry&&['rec','tra'].includes(order.state)) - box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();})); - card.append(box); -} -function paymentIssues(){ - $('payments').replaceChildren(); - if(!board.payment_issues?.length)return; - $('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length)); - $('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+ - 'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta')); - for(const issue of board.payment_issues){ - const card=node('article',undefined,'review'); - card.append(node('b',issue.provider+' · evento '+issue.event_id), - node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+ - (issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'), - node('p',issue.outcome)); - card.append(action('Registrar resolução',async()=>{ - const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return; - await api('/payment-events/'+issue.id+'/resolve',{note});await load(); - })); - $('payments').append(card); - } +function manifest(production){ + const url=URL.createObjectURL(new Blob([JSON.stringify(production,null,2)],{type:'application/json'})); + const link=node('a');link.href=url;link.download='dtf-layout-'+production.sources[0].upload_id.slice(0,8)+'.json'; + link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); } + +// ---- Loading and tabs ------------------------------------------------------ + async function load(){ try{ board=await api('/board'); extraQuotes={pending:[],approved:[]}; moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length, approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length}; - $('login').hidden=true; - $('status').textContent='Atualizado às '+new Date().toLocaleTimeString()+tinyNotice; + $('login').hidden=true;$('app').hidden=false; + say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice); render(); - }catch(e){$('status').textContent=e.message;$('login').hidden=false;} + }catch(e){ + if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;} + // Not being signed in yet is the normal start, not an error. + if(e.status===401){say('');return;} + say(e.message,true); + } } -async function loadMoreQuotes(kind){ - const shown=[...board.quotes.filter(q=>kind==='pending'?!q.approved:!!q.approved),...extraQuotes[kind]]; - const last=shown.at(-1); - const params=new URLSearchParams({kind,limit:'50'}); - if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);} - const page=await api('/quotes?'+params); - const known=new Set(shown.map(q=>q.id)); - extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id))); - moreQuotes[kind]=page.has_more; - render(); +function showTab(name){ + tab=name; + for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name)); + $('tab-board').hidden=name!=='board';$('tab-quotes').hidden=name!=='quotes';$('tab-integrations').hidden=name!=='integrations'; } -// The one-time authorisation of this system in the client's Tiny. Shown only -// when the Tiny application is configured on the server. -function tinyStatus(){ - const box=$('tiny');box.replaceChildren(); - const tiny=board.tiny||{}; - if(!tiny.configured)return; - box.append(node('span',tiny.connected - ? 'Tiny conectado'+(tiny.orders_enabled?'':' · envio de pedidos desligado') - : 'Tiny não conectado')); - box.append(action(tiny.connected?'Reconectar Tiny':'Conectar Tiny',async()=>{ - const result=await api('/tiny/connect',{});location.href=result.url; +function render(){ + $('who').textContent=board.operator||''; + $('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length); + $('badge-quotes').textContent=board.pending_total?String(board.pending_total):''; + $('badge-payments').textContent=board.payment_issues?.length?String(board.payment_issues.length):''; + tinyHeader();renderBoard();renderQuotes();renderIntegrations(); + if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();} +} + +// ---- Production board ---------------------------------------------------- + +function printState(order){ + if(order.state==='fil'||order.state==='imp')return {text:'Final aprovado',tone:'ok'}; + if(order.state==='fin')return {text:'Concluído',tone:'ok'}; + if(order.state==='cor')return {text:'Aguardando o cliente',tone:''}; + const rows=order.print_files||[], items=order.snapshot.items.length; + const ready=rows.filter(r=>r.status==='ready').length; + if(rows.some(r=>r.status==='pending'||r.status==='rendering'))return {text:'Gerando arquivo…',tone:''}; + if(ready===items)return {text:'PDF pronto',tone:'ok'}; + if(rows.some(r=>r.status==='manual'||r.status==='failed')) + return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'}; + return {text:'Sem arquivo gerado',tone:'warn'}; +} +function matches(order){ + const modes=order.snapshot.items.map(i=>i.mode); + if(filter==='textil'&&!modes.some(m=>m==='file'||m==='avulsa'))return false; + if(filter==='uv'&&!modes.some(m=>m==='uvfile'||m==='uv'))return false; + if(filter==='manual'&&!(order.print_files||[]).some(r=>r.status==='manual'||r.status==='failed'))return false; + if(!search)return true; + const c=order.snapshot.customer, d=order.snapshot.destination||{}; + const hay=[String(order.number),c.mail,c.cnpj,d.recipient,d.city].join(' ').toLowerCase(); + const digits=search.replace(/\D/g,''); + return hay.includes(search)||(digits.length>=3&&hay.replace(/\D/g,'').includes(digits)); +} +function renderBoard(){ + const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']]; + $('filters').replaceChildren(...filters.map(([key,label])=>{ + const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(filter===key)); + b.onclick=()=>{filter=key;renderBoard();};return b;})); + const active=board.orders.filter(o=>['rec','tra','fil','imp'].includes(o.state)); + const queued=active.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0); + $('board-summary').textContent=metres(queued)+' em produção'; + $('kan').replaceChildren(...Object.entries(board.states).map(([state,title])=>{ + const column=node('section',undefined,'col');column.dataset.state=state; + const orders=board.orders.filter(o=>o.state===state&&matches(o)); + const total=orders.reduce((s,o)=>s+o.snapshot.items.reduce((t,i)=>t+Number(i.billed_metres),0),0); + const head=node('div',undefined,'col-head');const line=node('div'); + const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]); + // Finished orders are a recent window, not the whole history: say so. + const count=state==='fin'&&board.finished_total>board.finished_shown?orders.length+' de '+board.finished_total:String(orders.length); + line.append(swatch,node('h2',title),node('span',count,'n')); + head.append(line,node('small',orders.length?metres(total)+' no total':'vazio')); + column.append(head); + for(const order of orders)column.append(card(order)); + column.ondragover=e=>{e.preventDefault();column.classList.add('target');}; + column.ondragleave=()=>column.classList.remove('target'); + column.ondrop=async e=>{e.preventDefault();column.classList.remove('target'); + const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain')); + if(order&&order.state!==state)try{await move(order,state);}catch(error){say(error.message,true);}}; + return column; })); } -const tinyResult=new URLSearchParams(location.search).get('tiny'); -const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):''; -if(tinyResult)history.replaceState(null,'',location.pathname); -function render(){ - tinyStatus(); - paymentIssues(); - $('reviews').replaceChildren(); - if(board.pending_total || board.approved_total) - $('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+ - board.approved_total+' aprovadas sem pedido')); - for(const quote of [...board.quotes,...extraQuotes.pending,...extraQuotes.approved]){ - const card=node('article',undefined,'review'); - card.append(node('b',quote.id.slice(0,8)+' · '+quote.draft.customer.mail)); - if(quote.status==='pending_review' && quote.draft.items.some(item=>item.production?.version!==2)){ - card.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.')); - }else if(quote.status!=='pending_review'){ - card.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':'Aprovada: '+money(quote.approved.total_cents)+'. Volte ao Site, atualize o pedido e confirme o pagamento local.')); - }else{ - const form=node('form'); - const edits=quote.draft.items.map((item,index)=>{ - const row=node('div');row.append(node('strong',(index+1)+'. '+item.mode+' ')); - productionLines(row,item); - const metres=node('input');metres.type='number';metres.min='0.001';metres.max='12000';metres.step='any';metres.value=item.metres;metres.required=true; - const grade=node('input');grade.type='number';grade.min='0';grade.max='100';grade.step='1';grade.value=item.grade;grade.required=true; - const ml=node('label','Metros conferidos');ml.append(metres);const gl=node('label','Nota conferida');gl.append(grade);row.append(ml,gl);form.append(row); - return ()=>({...item,metres:metres.value,grade:Number(grade.value)}); - }); - const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi os arquivos, a metragem total (incluindo repetições/montagem) e a nota.');label.prepend(check);form.append(label); - const submit=node('button','Aprovar cotação');submit.type='submit';form.append(submit); - form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}catch(error){$('status').textContent=error.message;submit.disabled=false;}}; - card.append(form); - } - const downloads=node('div',undefined,'actions');files(downloads,quote.draft.items);card.append(downloads);$('reviews').append(card); - } - for(const [kind,label] of [['pending','Carregar cotações pendentes mais antigas'], - ['approved','Carregar cotações aprovadas mais antigas']]){ - if(moreQuotes[kind])$('reviews').append(action(label,()=>loadMoreQuotes(kind))); - } - $('kan').replaceChildren(); - const colors=['#00b8da','#e0357c','#efb700','#edebe6','#e0642a','#48b072']; - Object.entries(board.states).forEach(([state,title],index)=>{ - const column=node('section',undefined,'col');column.dataset.state=state;column.style.setProperty('--cc',colors[index]); - const orders=board.orders.filter(o=>o.state===state); - // Finished orders are a recent window, not the whole history: say so rather - // than let the count read as an all-time total. - const count=state==='fin'&&board.finished_total>orders.length - ? orders.length+' de '+board.finished_total : String(orders.length); - column.append(node('h2',title+' · '+count)); - for(const order of orders){ - const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id; - card.append(node('b','#'+order.number+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents))); - const to=order.snapshot.destination; - card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+ - ' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta')); - for(const item of order.snapshot.items){ - card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta')); - productionLines(card,item); - } - printFiles(card,order); - const actions=node('div',undefined,'actions');files(actions,order.snapshot.items); - const artwork=node('div'); - actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork))); - actions.append(action('Histórico',async()=>{const rows=await api('/orders/'+order.id+'/history');alert(rows.map(r=>board.states[r.from_state]+' → '+board.states[r.to_state]+' · '+r.operator+(r.reason?' · '+r.reason:'')).join('\n')||'Pedido recebido.');})); - for(const next of board.transitions[state])actions.append(action('→ '+board.states[next],()=>move(order,next))); - card.append(actions,artwork);card.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id);column.append(card); - } - column.ondragover=e=>{e.preventDefault();column.classList.add('alvo');};column.ondragleave=()=>column.classList.remove('alvo'); - column.ondrop=async e=>{e.preventDefault();column.classList.remove('alvo');const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));if(order)try{await move(order,state);}catch(error){$('status').textContent=error.message;}}; - $('kan').append(column); - }); - $('events').textContent=board.events.map(e=>e.provider+' · '+e.payload.event+' · pedido #'+e.payload.number+' · '+(e.delivered_at?'registrado localmente':'pendente')+' · tentativas '+e.attempts).join('\n')||'Nenhum evento.'; +function card(order){ + const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true; + if(order.id===openOrder)c.classList.add('open'); + const top=node('div',undefined,'row'); + top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age')); + const tags=node('div',undefined,'tags'); + for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag')); + const foot=node('div',undefined,'foot');const p=printState(order); + const status=node('span',p.text,p.tone);status.style.display='flex';status.style.alignItems='center';status.style.gap='5px'; + if(p.tone==='ok')status.prepend(icon(ICON_OK));if(p.tone==='warn')status.prepend(icon(ICON_WARN)); + const dest=order.snapshot.destination; + foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where')); + c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot); + if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000) + c.append(node('div','Parado há mais de '+LATE_HOURS+' h nesta etapa','late')); + c.onclick=()=>openPanel(order.id); + c.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id); + return c; } -async function move(order,state){ - let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;} - await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load(); +async function move(order,state,reason=''){ + await api('/orders/'+order.id+'/move',{state,version:order.version,reason}); + await load(); } -$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}}; -$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();}; -$('refresh').onclick=load; -load(); -async function artworkPanel(order,container){ - container.replaceChildren(); - const revisions=await api('/orders/'+order.id+'/files'); - for(const file of revisions){ - const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta'); - row.append(node('p',file.note)); - if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id))); - container.append(row); +// ---- Order panel --------------------------------------------------------- + +function openPanel(id){openOrder=id;const order=board.orders.find(o=>o.id===id);$('drawer').hidden=false;renderPanel(order);renderBoard();} +function closePanel(){openOrder=null;$('drawer').hidden=true;$('panel').replaceChildren();if(board)renderBoard();} +$('scrim').onclick=closePanel; +document.addEventListener('keydown',e=>{if(e.key==='Escape'&&openOrder)closePanel();}); + +function layoutPreview(production){ + const ns='http://www.w3.org/2000/svg', w=Number(production.film_width_cm), h=Math.max(1,Number(production.height_cm)); + const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 '+w+' '+h);svg.setAttribute('role','img'); + svg.setAttribute('aria-label','Montagem: '+production.placements.length+' peças em '+w+' × '+h+' cm'); + const film=document.createElementNS(ns,'rect');film.setAttribute('width',w);film.setAttribute('height',h);film.setAttribute('fill','#0F1113');svg.append(film); + const colors=['#2BB6D9','#E0508F','#E9C23A','#6FD19C','#FF8B2B','#A78BFA']; + for(const p of production.placements){const r=document.createElementNS(ns,'rect'); + r.setAttribute('x',p.x_cm);r.setAttribute('y',p.y_cm);r.setAttribute('width',p.width_cm);r.setAttribute('height',p.length_cm); + r.setAttribute('fill',colors[p.source_index%colors.length]);r.setAttribute('fill-opacity','.55');svg.append(r);} + return svg; +} +function renderPanel(order){ + const token=++openToken, panel=$('panel');panel.dataset.order=order.id; + const color=STAGE_COLORS[order.state]; + const head=node('div',undefined,'panel-head'); + const row=node('div',undefined,'row'); + const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color); + const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel; + row.append(node('span','#'+order.number,'num'),stage,node('span','pago '+when(order.created_at),'faint'),close); + const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas'); + const at=FLOW.indexOf(order.state); + for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]); + if(at>=0&&iitemsList.append(itemCard(order,item,index))); + items.append(itemsList); + const total=node('p',undefined,'muted');total.append('Total pago: ',node('b',money(s.total_cents)));items.append(total); + const finals=node('section',undefined,'finals');finals.append(node('p','Arquivos finais','eyebrow'),node('p','Carregando…','faint')); + const history=node('section');history.append(node('p','Histórico','eyebrow'),node('p','Carregando…','faint')); + body.append(facts,items,finals,history); + panel.replaceChildren(head,body); + + api('/orders/'+order.id+'/files').then(rows=>{if(token===openToken)finalsSection(order,rows,finals);}).catch(e=>say(e.message,true)); + api('/orders/'+order.id+'/history').then(rows=>{if(token===openToken)historySection(order,rows,history);}).catch(e=>say(e.message,true)); +} +function moves(order){ + const box=node('div');box.style.display='flex';box.style.flexDirection='column';box.style.gap='10px'; + const row=node('div',undefined,'moves'); + const reason=node('form',undefined,'reason');reason.hidden=true; + for(const next of board.transitions[order.state]){ + if(next==='cor'){ + const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor'; + b.onclick=()=>{reason.hidden=!reason.hidden;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b); + }else{ + const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b); + } } - if(!['rec','tra','cor'].includes(order.state))return; - const form=node('form'); - form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.')); - // A generated print file reproduces the approved layout; offer it first, and - // keep the upload for items that need hand preparation. Not after a - // correction: the file was made from artwork the customer has replaced. + if(!row.children.length)row.append(node('span','Pedido concluído.','muted')); + const text=node('textarea');text.placeholder='O que o cliente precisa corrigir? (vai para o cliente)';text.maxLength=1000;text.required=true; + const confirm=node('button','Enviar para correção','btn danger'); + reason.append(text,confirm); + reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}}; + box.append(row,reason);return box; +} +const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'}; +function itemCard(order,item,index){ + const row=(order.print_files||[]).find(r=>r.item_index===index); + const box=node('div',undefined,'item'); + if(row&&(row.status==='manual'||row.status==='failed'))box.classList.add('attention'); + const preview=node('div',undefined,'preview'); + if(item.production?.placements)preview.append(layoutPreview(item.production)); + const body=node('div',undefined,'body'); + const title=node('div',undefined,'title');title.append(node('b',PRODUCT[item.mode]),node('span',metres(item.billed_metres),'mono')); + const spec=item.production; + const line=spec?(spec.placements.length+' peças em '+spec.film_width_cm+' × '+spec.height_cm+' cm · nota '+item.grade):('nota '+item.grade); + body.append(title,node('div',line,'line')); + const file=node('div',undefined,'file'); + if(row){ + const tone=row.status==='ready'?'ok':row.status==='manual'||row.status==='failed'?'warn':'muted'; + const label=node('span',PRINT_TEXT[row.status]+(row.status==='ready'&&row.name?' · '+row.name:'')+(row.status==='manual'&&row.detail.reason?': '+row.detail.reason:''),tone); + file.append(tone==='ok'?icon(ICON_OK):tone==='warn'?icon(ICON_WARN):'',label); + if(row.status==='ready'&&row.detail){body.append(file,node('div',[row.detail.film_width_cm+' × '+row.detail.height_cm+' cm', + row.detail.min_dpi?'menor resolução '+row.detail.min_dpi+' DPI':'',row.detail.vector_sources?'PDF vetorial':''].filter(Boolean).join(' · '),'line'));} + else body.append(file); + }else body.append(node('div','Arquivo de impressão ainda não gerado','line')); + const buttons=node('div',undefined,'buttons'); + if(row?.status==='ready')buttons.append(button('Baixar PDF',download(row.upload_id))); + item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost'))); + if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost')); + if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed')) + buttons.append(button('Gerar de novo',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost')); + body.append(buttons); + box.append(preview,body);return box; +} +function finalsSection(order,revisions,section){ + section.replaceChildren(node('p','Arquivos finais','eyebrow')); + for(const file of revisions){ + const rev=node('div',undefined,'rev'); + rev.append(node('b',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name), + node('span',(file.expired?'expirado':file.active?'atual':'substituído')+' · '+when(file.created_at)+' · '+file.note,'faint')); + if(!file.expired){const b=button('Baixar',download(file.upload_id),'btn ghost');b.style.alignSelf='flex-start';rev.append(b);} + section.append(rev); + } + if(!['rec','tra','cor'].includes(order.state)){if(!revisions.length)section.append(node('p','Nenhum arquivo final registrado.','faint'));return;} + // A generated print file reproduces the approved layout: offer it first. Not + // after a customer correction, which replaced the artwork it was made from. const corrected=revisions.some(file=>file.kind==='correction'); + const form=node('form'); + form.append(node('p','Aprove um conjunto final completo. Um novo conjunto substitui o anterior.','muted')); const inputs=order.snapshot.items.map((item,index)=>{ - const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block'; - const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%'; + const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]); + const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index; const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready'); if(generated){ - const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true; + const use=node('input');use.type='checkbox';use.checked=true;use.dataset.useGenerated=index;input.required=false;input.hidden=true; use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;}; - const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block'; - label.append(choice);input.generated=()=>use.checked?generated.upload_id:null; + const choice=node('label',undefined,'check');choice.append(use,'Usar o PDF gerado ('+generated.name+')'); + slot.append(choice);input.generated=()=>use.checked?generated.upload_id:null; } - label.append(input);form.append(label);return input;}); - const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note); - const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label); - const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit); + slot.append(input);form.append(slot);return input; + }); + const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000; + const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm=''; + const confirm=node('label',undefined,'check');confirm.append(check,'Conferi estes arquivos finais para impressão'); + const submit=node('button','Aprovar arquivos finais','btn primary'); + form.append(note,confirm,submit); form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{ const refs=[]; for(const [index,input] of inputs.entries()){ const generated=input.generated?.(); if(generated){refs.push({item_index:index,upload_id:generated});continue;} for(const file of input.files){ - const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text}); - refs.push({item_index:index,upload_id:uid}); - }} + const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>say(text)}); + refs.push({item_index:index,upload_id:uid}); + } + } await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value}); await load(); - }catch(error){$('status').textContent=error.message;submit.disabled=false;}}; - container.append(form); + }catch(error){say(error.message,true);submit.disabled=false;}}; + section.append(form); } +function historySection(order,rows,section){ + const list=node('ol',undefined,'timeline'); + for(const r of [...rows].reverse()){ + const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]); + const text=node('div');text.append(node('b',board.states[r.from_state]+' → '+board.states[r.to_state])); + if(r.reason)text.append(node('span',' · '+r.reason,'muted')); + text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li); + } + const first=node('li');first.style.setProperty('--c','var(--rec)'); + const t=node('div');t.append(node('b','Pedido recebido e pago'),node('small',when(order.created_at)+' · '+(board.environment==='local'&&order.payment?.provider==='fake'?'pagamento de teste':'Site'))); + first.append(t);list.append(first); + section.replaceChildren(node('p','Histórico','eyebrow'),list); +} + +// ---- Quotes -------------------------------------------------------------- + +function allQuotes(){return [...board.quotes,...extraQuotes.pending,...extraQuotes.approved];} +function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);} +async function loadMoreQuotes(kind){ + const shown=allQuotes().filter(q=>kind==='pending'?!q.approved:!!q.approved); + const last=shown.at(-1);const params=new URLSearchParams({kind,limit:'50'}); + if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);} + const page=await api('/quotes?'+params);const known=new Set(allQuotes().map(q=>q.id)); + extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));moreQuotes[kind]=page.has_more;renderQuotes(); +} +function renderQuotes(){ + const quotes=allQuotes(); + const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]]; + $('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{ + const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key)); + b.onclick=()=>{quoteKind=key;renderQuotes();};return b;})); + const visible=quotes.filter(q=>quoteKind==='pending'?q.status==='pending_review':q.status!=='pending_review'); + if(!pickedQuote||!quotes.some(q=>q.id===pickedQuote))pickedQuote=visible[0]?.id||null; + const list=visible.map(q=>{ + const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===pickedQuote)); + const left=node('div');left.append(node('b',q.draft.customer.mail), + node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q)))); + if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag')); + else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag')); + const right=node('div',undefined,'right'); + right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at))); + b.append(left,right);b.onclick=()=>{pickedQuote=q.id;renderQuotes();};return b; + }); + if(!list.length)list.push(node('p',quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint')); + if(moreQuotes[quoteKind])list.push(button('Carregar mais antigas',()=>loadMoreQuotes(quoteKind),'btn ghost')); + $('quote-list').replaceChildren(...list); + const quote=quotes.find(q=>q.id===pickedQuote); + $('reviews').replaceChildren(quote?review(quote):node('p','Selecione uma cotação.','note')); +} +function review(quote){ + const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id; + const head=node('div',undefined,'head');const title=node('div'); + title.append(node('b',quote.draft.customer.mail),node('div','Cotação '+quote.id.slice(0,8)+' · enviada '+ago(quote.created_at)+' · '+ + (quote.draft.destination?'entrega em '+quote.draft.destination.city+'/'+quote.draft.destination.state:'retirada em Franca'),'muted')); + head.append(title); + const content=node('div',undefined,'content'); + const foot=node('div',undefined,'foot'); + if(quote.status==='pending_review'&"e.draft.items.some(item=>item.production?.version!==2)){ + content.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.','muted')); + card.append(head,content);return card; + } + if(quote.status!=='pending_review'){ + content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.': + 'Aprovada: '+money(quote.approved.total_cents)+'. Aguardando o pagamento do cliente.','muted')); + for(const item of quote.draft.items)content.append(quoteItem(item,null)); + card.append(head,content);return card; + } + const form=node('form');form.style.display='contents'; + const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields)); + return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});}); + const check=node('input');check.type='checkbox';check.required=true; + const confirm=node('label',undefined,'check');confirm.append(check,'Conferi arquivos, metragem (com repetições e montagem) e nota'); + const submit=node('button','Aprovar cotação','btn primary'); + foot.append(confirm,submit); + form.append(content,foot); + form.onsubmit=async e=>{e.preventDefault();submit.disabled=true; + try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();} + catch(error){say(error.message,true);submit.disabled=false;}}; + card.append(head,form);return card; +} +function quoteItem(item,fields){ + const box=node('div',undefined,'item'); + const preview=node('div',undefined,'preview');preview.style.width='160px'; + if(item.production?.placements)preview.append(layoutPreview(item.production)); + const body=node('div',undefined,'body'); + const title=node('div',undefined,'title'); + const quality=item.quality_status==='ok'?'qualidade ok':item.quality_status==='warning'?'ressalva '+(item.quality_acknowledged?'aceita':'não aceita'):'qualidade não verificada'; + title.append(node('b',PRODUCT[item.mode]),node('span',quality+' · nota '+item.grade,item.quality_status==='ok'?'ok':'warn')); + body.append(title); + if(item.production)body.append(node('div',item.production.sources.length+' arquivo(s) · '+item.production.placements.length+' peças · '+ + item.production.film_width_cm+' × '+item.production.height_cm+' cm','line')); + if(fields){ + const grid=node('div',undefined,'fields'); + const asked=node('label','Metros do cliente');const a=node('input');a.value=item.metres;a.readOnly=true;asked.append(a); + const m=node('label','Metros conferidos');fields.metres=node('input');Object.assign(fields.metres,{type:'number',min:'0.001',max:'12000',step:'any',value:item.metres,required:true});m.append(fields.metres); + const g=node('label','Nota conferida');fields.grade=node('input');Object.assign(fields.grade,{type:'number',min:'0',max:'100',step:'1',value:item.grade,required:true});g.append(fields.grade); + grid.append(asked,m,g);body.append(grid); + } + const buttons=node('div',undefined,'buttons'); + item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost'))); + if(item.production?.placements)buttons.append(button('Manifesto',()=>manifest(item.production),'btn ghost')); + body.append(buttons);box.append(preview,body);return box; +} + +// ---- Payments and integrations ------------------------------------------ + +const tinyResult=new URLSearchParams(location.search).get('tiny'); +const tinyNotice=tinyResult?(tinyResult==='connected'?' · Tiny conectado.':' · Não foi possível conectar o Tiny. Tente de novo.'):''; +if(tinyResult)history.replaceState(null,'',location.pathname); +function tinyHeader(){ + const t=board.tiny||{};const el=$('tiny'); + if(!t.configured){el.textContent='';return;} + el.textContent=t.connected?'Tiny conectado':'Tiny não conectado'; + el.style.setProperty('--dot',t.connected?'var(--fin)':'var(--warn)'); +} +function integration(name,state,tone,text,actions=[]){ + const box=node('div',undefined,'integration'); + const head=node('div',undefined,'head');const dot=node('span',undefined,'dot'); + const colors={ok:['var(--fin)','var(--ok)'],warn:['var(--warn)','var(--warn)'],off:['#5B6168','var(--muted)']}[tone]; + dot.style.setProperty('--dot',colors[0]);const em=node('em',state);em.style.setProperty('--dotx',colors[1]); + head.append(dot,node('b',name),em);box.append(head,node('p',text)); + if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);} + return box; +} +function renderIntegrations(){ + const issues=board.payment_issues||[]; + const payments=[node('h2','Pagamentos que precisam de atenção')]; + if(!issues.length)payments.push(node('p','Nenhum pagamento pendente de resolução.','faint')); + for(const issue of issues){ + const box=node('div',undefined,'issue');box.append(icon(ICON_WARN));box.firstChild.style.color='var(--warn)'; + const text=node('div',undefined,'text'); + text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)), + node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at))); + const form=node('form');const note=node('input');note.placeholder='O que foi feito? (ex.: estornado em 25/09)';note.required=true;note.minLength=3;note.maxLength=1000; + const save=node('button','Registrar resolução','btn warn');form.append(note,save); + form.onsubmit=async e=>{e.preventDefault();save.disabled=true; + try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});await load();}catch(error){say(error.message,true);save.disabled=false;}}; + box.append(text,form);payments.push(box); + } + $('payments').replaceChildren(...payments); + + const t=board.tiny||{};const cards=[]; + if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Defina TINY_CLIENT_ID, TINY_CLIENT_SECRET e TINY_REDIRECT_URI no servidor.')); + else{ + const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn'); + const actions=[connect]; + if(t.connected){const result=node('p'); + actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{}); + say(r.ok?'Tiny respondeu: leitura de pedidos e contatos ok.':'Tiny: pedidos '+r.results.pedidos+' · contatos '+r.results.contatos,!r.ok);}));} + cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn', + t.connected?'Por '+t.connected_by+' desde '+when(t.connected_at)+'. Envio de pedidos '+(t.orders_enabled?'ligado.':'desligado.'): + 'Clique em Conectar e aprove o acesso com um login do Tiny.',actions)); + } + const mp=board.providers?.payment; + cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off', + mp==='mercadopago'?'PIX e cartão ativos no Site.':'Aguardando a conta e as chaves do Mercado Pago. O Site ainda não recebe pagamentos.')); + cards.push(integration('Frete','Não configurado','off','Aguardando a plataforma de frete. Por enquanto só retirada em Franca.')); + cards.push(integration('WhatsApp','Não configurado','off','As mensagens ficam registradas abaixo, sem envio ao cliente.')); + $('integrations').replaceChildren(...cards); + + $('events').replaceChildren(...board.events.map(e=>{ + const tr=node('tr');const receipt=e.receipt||{};let result, cls=''; + if(e.delivered_at){ + if(receipt.status==='created'){result='Criado no Tiny nº '+receipt.tiny_number;cls='good';} + else if(receipt.status==='already-created'){result='Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)';cls='good';} + else if(receipt.status==='not-applicable'){result='Não enviado (o Tiny recebe só a venda)';} + else result='Registrado, sem envio'; + }else if(e.last_error){result='Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')';cls='retry';} + else result='Na fila'; + const provider={tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider; + tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),node('td',provider), + node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls)); + return tr; + })); + if(!board.events.length){const tr=node('tr');const td=node('td','Nenhum envio ainda.','faint');td.colSpan=5;tr.append(td);$('events').append(tr);} +} + +// ---- Wiring -------------------------------------------------------------- + +for(const b of document.querySelectorAll('[data-tab]'))b.onclick=()=>showTab(b.dataset.tab); +$('search').oninput=e=>{search=e.target.value.trim().toLowerCase();renderBoard();if(search)showTab('board');}; +$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){say(error.message,true);}finally{$('password').value='';}}; +$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();}; +$('refresh').onclick=load; +load(); diff --git a/web/portal.html b/web/portal.html index 03831ca..6e8ab8b 100644 --- a/web/portal.html +++ b/web/portal.html @@ -3,9 +3,9 @@ *{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#20252d;font:15px/1.5 system-ui,sans-serif}header{background:white;border-bottom:1px solid #ddd;padding:20px max(20px,calc((100% - 1100px)/2));display:flex;gap:24px;align-items:center;flex-wrap:wrap}.brand{color:#ef8500;font-size:24px;font-style:italic;font-weight:900}a{color:#8a4e00}main{max-width:1100px;margin:24px auto;padding:0 20px}h1{font-size:28px}h2{font-size:21px}h3{font-size:17px}.notice{background:#fff1d4;border-left:4px solid #ffa900;padding:12px 16px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:20px}.card{background:white;border:1px solid #ddd;border-radius:10px;padding:20px;margin:16px 0}.card p{overflow-wrap:anywhere}.muted{color:#596471}label{display:block;margin:10px 0}input,textarea,button{font:inherit;border:1px solid #9aa0a8;border-radius:6px;padding:10px}input:not([type=file]),textarea{width:100%}button{background:#ffa900;border-color:#ffa900;cursor:pointer}button:disabled{opacity:.6}form{margin:10px 0}.actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#message{min-height:24px;color:#9b3800}li{margin:8px 0}.tag{background:#eef2f6;padding:5px 10px;border-radius:6px}details{margin:12px 0}[hidden]{display:none!important}
DROPSTAREnviar arteMinha conta · DTF
-

Meus pedidos DTF

Ambiente de desenvolvimento local. Pagamentos e notificações são simulados. Use apenas dados de teste.

+

Meus pedidos DTF

Entrar

-

Criar conta local

Pedidos desta sessão serão associados à sua conta. Não há envio de e-mail nem recuperação de senha nesta versão local.

+

Criar conta

Pedidos desta sessão serão associados à sua conta. Guarde sua senha: a recuperação por e-mail ainda não está disponível.

Acompanhamento

diff --git a/web/portal.js b/web/portal.js index f06fde7..09a2b96 100644 --- a/web/portal.js +++ b/web/portal.js @@ -1,13 +1,14 @@ const $=id=>document.getElementById(id); const node=(tag,text)=>{const el=document.createElement(tag);if(text!==undefined)el.textContent=text;return el;}; const money=c=>(c/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); -let scope,states={}; +let scope,states={},environment=''; async function api(path,body){const r=await fetch('/api'+path,{headers:{'Content-Type':'application/json'},...(body===undefined?{}:{method:'POST',body:JSON.stringify(body)})});const d=await r.json();if(!r.ok){const error=new Error(typeof d.detail==='string'?d.detail:'Confira os campos informados.');error.status=r.status;throw error;}return d;} function button(text,fn){const b=node('button',text);b.onclick=async()=>{b.disabled=true;try{await fn();}catch(e){$('message').textContent=e.message;}finally{b.disabled=false;}};return b;} function forgetCheckout(){for(const k of ['dtf-quote','dtf-request-key','dtf-request-body'])localStorage.removeItem(k);} async function load(){ try{ - scope=(await api('/session')).cart_scope; + const session=await api('/session');scope=session.cart_scope;environment=session.environment; + $('dev-notice').hidden=environment!=='local'; const account=await api('/account/me'); $('auth').hidden=!!account.customer;$('logout').hidden=!account.customer; $('account').textContent=account.customer?'Conta: '+account.customer.mail:''; @@ -23,7 +24,7 @@ async function load(){ if(!data.orders.length)$('orders').append(node('p','Nenhum pedido pago nesta conta ou sessão.')); for(const order of data.orders){ const card=node('article');card.className='card';card.id='order-'+order.id; - card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+' · pagamento local')); + card.append(node('h3','Pedido #'+order.number),node('p',states[order.state]+' · '+money(order.snapshot.total_cents)+(environment==='local'?' · pagamento de teste':''))); card.append(node('p',new Date(order.created_at).toLocaleString('pt-BR'))); const content=node('div'); card.append(button('Ver detalhes e arquivos',()=>details(order.id,content)),content);$('orders').append(card); @@ -71,6 +72,6 @@ async function authenticate(path,body){ } $('login').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/login',{email:$('email').value,password:$('password').value});}catch(e){$('message').textContent=e.message;}}; $('register').onsubmit=async event=>{event.preventDefault();try{await authenticate('/account/register',{customer:{cnpj:$('cnpj').value,zap:$('phone').value,mail:$('register-email').value},password:$('register-password').value});}catch(e){$('message').textContent=e.message;}}; -$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível concluir a limpeza local. Feche as abas do Site e limpe os dados deste site no navegador.';}}; +$('logout').onclick=async()=>{try{await window.dtfForgetPrivateData();await api('/account/logout',{});location.reload();}catch(error){$('message').textContent='Não foi possível limpar os dados deste navegador. Feche as abas do Site e limpe os dados do site nas configurações do navegador.';}}; $('refresh').onclick=load; load();