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();