diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 038a928..6b80f86 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -73,7 +73,7 @@ jobs: # one a developer exercises on localhost. - name: API and workflow regressions run: | - for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do + for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test kanban_test print_file_test; do echo "--- $suite" $COMPOSE exec -T \ -e SITE_BASE_URL=http://site \ diff --git a/app/api/operator.py b/app/api/operator.py index d12341d..925eff5 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -1,6 +1,7 @@ """Kanban: sign-in, the board, commercial review and card movement.""" import hashlib import os +import re import secrets from datetime import datetime, timedelta, timezone from typing import Literal @@ -108,14 +109,36 @@ def board(user=Depends(operator)): issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n'] backup = backup_status(c) + quotes = [quote_view(c, q) for q in pending + approved] return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK, 'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user, 'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT, 'finished_shown': len(finished), 'finished_total': finished_total, - 'quotes': [quote_view(c, q) for q in pending + approved], + 'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes), 'pending_total': pending_total, 'approved_total': approved_total} +def uploads_of(rows): + """Every upload id an order (its snapshot) or a quote (its draft) refers to.""" + ids = set() + for row in rows: + content = row.get('snapshot') or row.get('draft') or {} + for item in content.get('items') or []: + ids.update(str(uid) for uid in item.get('uploads') or []) + return ids + + +def thumbnails(c, *groups): + """The uploads among these orders and quotes that have an artwork picture, + so the Kanban asks only for pictures that exist.""" + ids = set().union(*(uploads_of(rows) for rows in groups)) + if not ids: + return [] + rows = c.execute('SELECT upload_id FROM dtf_local.upload_thumbnails WHERE upload_id=ANY(%s::uuid[])', + (list(ids),)).fetchall() + return [str(r['upload_id']) for r in rows] + + def with_print_files(c, orders): generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id @@ -141,7 +164,8 @@ def finished_page(before_created_at: datetime | None = None, before_id: UUID | N ''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + ''' ORDER BY created_at DESC,id DESC LIMIT %s''', ((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall() - return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit} + orders = with_print_files(c, rows[:limit]) + return {'orders': orders, 'has_more': len(rows) > limit, 'thumbnails': thumbnails(c, orders)} @router.get('/api/operator/payment-events') @@ -187,27 +211,97 @@ def events(provider: Literal['tiny','whatsapp'] | None = None, total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n'] return {'events': rows, 'total': total} +MODES = {'textil': {'file', 'avulsa'}, 'uv': {'uvfile', 'uv'}, + 'folha': {'file', 'uvfile'}, 'avulsa': {'avulsa', 'uv'}} + + +def like(text): + """A LIKE pattern that matches the text anywhere, its own % and _ literal.""" + return '%' + re.sub(r'([\\%_])', r'\\\1', text) + '%' + + +def numeric(q): + """The digits of a query made of digits and punctuation only ("#1042", + "11.222.333/0001-81", "(16) 99999-0000"); empty when it has letters.""" + return '' if re.search(r'[^\W\d_]', q) else re.sub(r'\D', '', q) + + +def customer_match(column, q): + """SQL and parameters matching a customer (e-mail, CNPJ or WhatsApp) in an + order snapshot or a quote draft. CNPJ and phone match by their digits, and + only when nothing but digits and punctuation was typed: the digits of an + e-mail address are not a phone number.""" + text, digits = q.strip().lower(), numeric(q) + sql = f"lower({column}->'customer'->>'mail') LIKE %s" + params = [like(text)] + if len(digits) >= 3: + sql += (f" OR regexp_replace(coalesce({column}->'customer'->>'cnpj',''),'\\D','','g') LIKE %s" + f" OR regexp_replace(coalesce({column}->'customer'->>'zap',''),'\\D','','g') LIKE %s") + params += [like(digits), like(digits)] + return '(' + sql + ')', params + + @router.get('/api/operator/quotes') def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None, before_id: UUID | None = None, offset: int = Query(default=0, ge=0), - limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)): - """Unpaid quotes, newest first: by cursor, or by page (offset) with a total.""" + limit: int = Query(default=50, ge=1, le=100), + q: str | None = Query(default=None, max_length=100), + product: Literal['textil','uv'] | None = None, layout: Literal['folha','avulsa'] | None = None, + flag: Literal['ressalva'] | None = None, user=Depends(operator)): + """Unpaid quotes, newest first: by cursor, or by page (offset) with a total; + filtered by customer, product, layout and an accepted resolution warning.""" if (before_created_at is None) != (before_id is None): raise HTTPException(422, 'Both quote cursor fields are required') - approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL' + clauses = ['o.id IS NULL', 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'] + params = [] + if q and q.strip(): + sql, values = customer_match('q.draft', q) + clauses.append(sql); params += values + if product or layout: + modes = set.intersection(*(MODES[k] for k in (product, layout) if k)) + clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'mode'=ANY(%s))") + params.append(sorted(modes)) + if flag: + clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'quality_status'='warning')") + where = ' AND '.join(clauses) cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else '' skip = 0 if before_created_at else offset - params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip) + page = params + ([before_created_at, before_id] if before_created_at else []) + [limit+1, skip] with db.connect() as c: rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id - WHERE o.id IS NULL AND {approved_filter} {cursor} - ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall() + WHERE {where} {cursor} + ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', page).fetchall() total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id - WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n'] - return {'quotes':[quote_view(c,row) for row in rows[:limit]], - 'has_more':len(rows)>limit, 'total': total} + WHERE {where}''', params).fetchone()['n'] + quotes = [quote_view(c,row) for row in rows[:limit]] + return {'quotes': quotes, 'has_more': len(rows)>limit, 'total': total, + 'thumbnails': thumbnails(c, quotes)} + + +@router.get('/api/operator/search') +def search(q: str = Query(min_length=2, max_length=100), user=Depends(operator)): + """One search over orders (any stage) and unpaid quotes: customer e-mail, + CNPJ or WhatsApp, the delivery recipient, or the order number.""" + match, params = customer_match('snapshot', q) + digits = numeric(q) + sql = match + " OR lower(coalesce(snapshot->'destination'->>'recipient','')) LIKE %s" + params.append(like(q.strip().lower())) + if digits and len(digits) <= 9: + sql += ' OR number=%s' + params.append(int(digits)) + quote_match, quote_params = customer_match('q.draft', q) + with db.connect() as c: + orders = c.execute(f'''SELECT * FROM dtf_local.orders WHERE {sql} + ORDER BY created_at DESC LIMIT 8''', params).fetchall() + rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q + LEFT JOIN dtf_local.orders o ON o.quote_id=q.id + WHERE o.id IS NULL AND {quote_match} + ORDER BY q.created_at DESC LIMIT 8''', quote_params).fetchall() + orders = with_print_files(c, orders) + quotes = [quote_view(c, row) for row in rows] + return {'orders': orders, 'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes)} @router.post('/api/operator/quotes/{uid}/approve') def approve(uid: UUID, body: Review, user=Depends(operator)): @@ -381,6 +475,17 @@ def history(uid: UUID, user=Depends(operator)): with db.connect() as c: return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall() +@router.get('/api/operator/uploads/{uid}/thumbnail') +def thumbnail(uid: UUID, user=Depends(operator)): + """The browser-made picture of an artwork. Cached by the operator's browser: + the board is redrawn on every refresh.""" + with db.connect() as c: + row = c.execute('SELECT mime,data FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (uid,)).fetchone() + if not row: + raise HTTPException(404, 'No picture for this upload') + return Response(bytes(row['data']), media_type=row['mime'], + headers={'Cache-Control': 'private, max-age=86400', 'Content-Disposition': 'inline'}) + @router.get('/api/operator/uploads/{uid}/download') def download(uid: UUID, user=Depends(operator)): with db.connect() as c: diff --git a/app/api/uploads.py b/app/api/uploads.py index 36e2c13..abde4c2 100644 --- a/app/api/uploads.py +++ b/app/api/uploads.py @@ -8,7 +8,8 @@ import os from uuid import UUID, uuid4 from botocore.exceptions import ClientError -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request +from starlette.concurrency import run_in_threadpool from ..core import db from ..core.auth import audit, owner, rate_limit @@ -94,6 +95,40 @@ def complete_upload(uid: UUID, session_id=Depends(owner)): (UNPAID_HOLD, uid)) return {'id': uid, 'complete': True} +# The browser's small picture of the artwork, for the Kanban. Only an image, and +# only a small one: the type is read from the bytes, never from the header. +THUMBNAIL_BYTES = 300_000 + +def thumbnail_type(data): + if data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return 'image/webp' + if data[:3] == b'\xff\xd8\xff': + return 'image/jpeg' + if data[:8] == b'\x89PNG\r\n\x1a\n': + return 'image/png' + return None + +def store_thumbnail(uid, session_id, mime, data): + rate_limit('upload-thumbnail', str(session_id), 120, 900) + with db.connect() as c: + upload_row(c, uid, session_id) + c.execute('''INSERT INTO dtf_local.upload_thumbnails(upload_id,mime,data) VALUES(%s,%s,%s) + ON CONFLICT(upload_id) DO UPDATE SET mime=EXCLUDED.mime, data=EXCLUDED.data, created_at=now()''', + (uid, mime, data)) + +@router.put('/api/uploads/{uid}/thumbnail') +async def put_thumbnail(uid: UUID, request: Request, session_id=Depends(owner)): + if int(request.headers.get('content-length') or 0) > THUMBNAIL_BYTES: + raise HTTPException(413, 'Thumbnail too large') + data = await request.body() + if len(data) > THUMBNAIL_BYTES: + raise HTTPException(413, 'Thumbnail too large') + mime = thumbnail_type(data) + if not mime: + raise HTTPException(415, 'Thumbnail must be a WebP, JPEG or PNG image') + await run_in_threadpool(store_thumbnail, uid, session_id, mime, data) + return {'id': uid} + @router.delete('/api/uploads/{uid}') def cancel_upload(uid: UUID, session_id=Depends(owner)): with db.connect() as c: @@ -102,5 +137,6 @@ def cancel_upload(uid: UUID, session_id=Depends(owner)): raise HTTPException(409, 'Completed upload cannot be cancelled') storage.discard(row['object_key'],row['multipart_id'],False) c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s',(uid,)) + c.execute('DELETE FROM dtf_local.upload_thumbnails WHERE upload_id=%s',(uid,)) audit('upload_cancelled', upload=str(uid)) return {'id':uid,'cancelled':True} diff --git a/app/app.py b/app/app.py index 8c4e939..186b931 100644 --- a/app/app.py +++ b/app/app.py @@ -37,7 +37,8 @@ async def safe_headers(request, call_next): response = await call_next(request) if response.status_code in (401,403,429) or response.status_code>=500: audit('http_security_event', method=request.method, status=response.status_code, ip=client_ip(request)) - response.headers['Cache-Control'] = 'no-store' + # Nothing is cached unless a route says so (the Kanban's artwork pictures). + response.headers.setdefault('Cache-Control', 'no-store') response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['Referrer-Policy'] = 'no-referrer' return response diff --git a/app/schema.sql b/app/schema.sql index b6ea0e1..4582fda 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -205,3 +205,11 @@ CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_event -- Payment intents look up by quote (customer retry) and by provider id (webhook). CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC); + +-- A small picture of each artwork (about 480 px), made by the customer's +-- browser from the file it already read, so the Kanban shows what an order is +-- without opening the original. It goes when the file's bytes go. +CREATE TABLE IF NOT EXISTS dtf_local.upload_thumbnails ( + upload_id uuid PRIMARY KEY REFERENCES dtf_local.uploads(id), + mime text NOT NULL, data bytea NOT NULL, created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/app/worker.py b/app/worker.py index c7dc39f..a0a2109 100644 --- a/app/worker.py +++ b/app/worker.py @@ -35,6 +35,7 @@ def cleanup(): for row in rows: storage.discard(row['object_key'],row['multipart_id'],row['complete']) c.execute('UPDATE dtf_local.uploads SET purged_at=now() WHERE id=%s', (row['id'],)) + c.execute('DELETE FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (row['id'],)) c.execute('DELETE FROM dtf_local.sessions WHERE expires_at<=now()') c.execute('DELETE FROM dtf_local.operator_sessions WHERE expires_at<=now()') c.execute("DELETE FROM dtf_local.login_attempts WHERE started_at', 'image/png')[0] == 415 + assert raw(client, 'PUT', f'/uploads/{uid}/thumbnail', PNG + b'\0' * 300_001, 'image/png')[0] == 413 + stranger = Client() + stranger.call('/session') + assert raw(stranger, 'PUT', f'/uploads/{uid}/thumbnail', PNG, 'image/png')[0] == 404 + + # Only an operator reads it, as an image the browser may keep for a while. + assert raw(client, 'GET', f'/operator/uploads/{uid}/thumbnail')[0] == 401 + client.call('/operator/board', operator=True) + status, headers, body = raw(client.operator_client, 'GET', f'/operator/uploads/{uid}/thumbnail') + assert status == 200 and body == PNG, status + assert headers['Content-Type'] == 'image/png' and 'private' in headers['Cache-Control'] + assert headers['X-Content-Type-Options'] == 'nosniff' + + owner = uuid4() + pending, approved, paid, order_id = uuid4(), uuid4(), uuid4(), uuid4() + item = lambda mode, warning=False: {'mode': mode, 'uploads': [uid], 'metres': '1.00', 'billed_metres': '1.00', + 'grade': 100, 'quality_status': 'warning' if warning else 'ok'} + draft = lambda *items: {'customer': customer, 'items': list(items), 'freight': {'service': 'pickup'}} + try: + with db.connect() as c: + for qid, content, accepted in [(pending, draft(item('avulsa', warning=True)), None), + (approved, draft(item('uvfile')), {'items': [], 'total_cents': 6990}), + (paid, draft(item('file')), {'items': [], 'total_cents': 1490})]: + c.execute('''INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,approved) + VALUES(%s,%s,%s,%s,%s,%s)''', (qid, owner, uuid4(), 'kanban-fixture', Jsonb(content), + Jsonb(accepted) if accepted else None)) + number = c.execute('''INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) + VALUES(%s,%s,%s,%s,%s) RETURNING number''', + (order_id, paid, owner, Jsonb({**draft(item('file')), 'total_cents': 1490}), + Jsonb({'provider': 'teste', 'id': f'kanban-{tag}'}))).fetchone()['number'] + + def quotes(**params): + page = client.call('/operator/quotes?' + urlencode({'q': mail, **params}), operator=True) + return page, {q['id'] for q in page['quotes']} + + page, ids = quotes(kind='pending') + assert ids == {str(pending)} and page['total'] == 1, ids + assert str(uid) in page['thumbnails'] + assert quotes(kind='pending', layout='avulsa', flag='ressalva')[1] == {str(pending)} + assert quotes(kind='pending', product='uv')[1] == set() + assert quotes(kind='approved', product='uv', layout='folha')[1] == {str(approved)} + assert quotes(kind='approved', layout='avulsa')[1] == set() + # CNPJ and WhatsApp match by digits, whatever the punctuation typed. + assert client.call('/operator/quotes?' + urlencode({'kind': 'approved', 'q': '11222333'}), + operator=True)['total'] >= 1 + # LIKE's own wildcards are literal: "%%" is not "everything". + assert str(pending) not in {q['id'] for q in client.call( + '/operator/quotes?' + urlencode({'kind': 'pending', 'q': '%%'}), operator=True)['quotes']} + + # One search: the paid order (by customer and by number) and the two unpaid quotes. + found = client.call('/operator/search?' + urlencode({'q': mail}), operator=True) + assert {o['id'] for o in found['orders']} == {str(order_id)} + assert {q['id'] for q in found['quotes']} == {str(pending), str(approved)} + assert str(uid) in found['thumbnails'] + by_number = client.call('/operator/search?' + urlencode({'q': f'#{number}'}), operator=True) + assert str(order_id) in {o['id'] for o in by_number['orders']} + assert client.call('/operator/search?q=a', operator=True, expected=422) + + board = client.call('/operator/board', operator=True) + assert str(uid) in board['thumbnails'] + print('PASS: artwork pictures (owner only, images only), quote filters and the order/quote search') + finally: + with db.connect() as c: + c.execute('DELETE FROM dtf_local.orders WHERE id=%s', (order_id,)) + c.execute('DELETE FROM dtf_local.quotes WHERE id=ANY(%s)', ([pending, approved, paid],)) + c.execute('DELETE FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (uid,)) + c.execute('DELETE FROM dtf_local.uploads WHERE id=%s', (uid,)) + + +if __name__ == '__main__': + run() diff --git a/web/checkout.js b/web/checkout.js index ff157c0..eee119f 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -118,6 +118,22 @@ } return e.promise; } + // The Kanban shows each order's artwork from the small picture this page + // already made of it (site-upload.js). Never blocks the order: a picture that + // fails to go leaves the Kanban with the layout drawing it shows today. + const fotosEnviadas=new Set(); + function enviaFoto(uid, foto) { + if (!uid || !foto || fotosEnviadas.has(uid)) return; + fotosEnviadas.add(uid); + try { + const [cabecalho, dados] = foto.split(','); + const bin = atob(dados), bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + fetch('/api/uploads/'+uid+'/thumbnail', {method:'PUT', body:bytes, + headers:{'Content-Type':cabecalho.slice(5).split(';')[0]}}) + .then(r=>{ if (!r.ok) fotosEnviadas.delete(uid); }, ()=>fotosEnviadas.delete(uid)); + } catch (e) { fotosEnviadas.delete(uid); } + } // A failed upload is shown and waits for the customer, never retried behind // their back: a file the check refused would fail again on every change. const falhas=new Map(); @@ -265,7 +281,10 @@ for (const item of cart) { if (!item.localFiles?.length) throw new Error('Selecione novamente os arquivos deste item.'); const uploads=[]; - for (const file of item.localFiles) uploads.push(await upload(file)); + for (const [index,file] of item.localFiles.entries()) { + uploads.push(await upload(file)); + enviaFoto(uploads[index], item.fotos?.[index]); + } if (item.production?.version!==2 || item.production.sources?.length!==uploads.length) throw new Error('A montagem deste item precisa ser refeita antes da cotação.'); items.push({mode:item.modo, metres:String(item.metros), grade:item.nota, uploads, diff --git a/web/kanban.css b/web/kanban.css index b9504bc..c80143c 100644 --- a/web/kanban.css +++ b/web/kanban.css @@ -51,8 +51,25 @@ input[type=file]{font-size:14px;color:var(--muted);max-width:100%} .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:14.5px} .tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)} .tabs .count{font-family:var(--mono);font-size:13.5px;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} +.searchbox{position:relative;margin-left:auto} +.search{display:flex;align-items:center;gap:8px;width:380px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint)} +.search:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,139,43,.18)} .search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none} +.search kbd{font:inherit;font-size:12px;padding:0 6px;border:1px solid var(--line3);border-radius:5px;color:var(--faint)} +.search:focus-within kbd{display:none} +/* The search results: orders and unpaid quotes, grouped. */ +.results{position:absolute;top:calc(100% + 8px);right:0;width:580px;max-height:min(70vh,580px);overflow:auto;z-index:8;padding:6px 0;border:1px solid var(--line3);border-radius:12px;background:var(--card);box-shadow:0 18px 48px rgba(0,0,0,.5)} +.rgroup{padding:8px 14px 4px;font-size:12.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--faint)} +.rhit{display:flex;align-items:center;gap:12px;width:100%;padding:8px 14px;border:0;background:transparent;text-align:left} +.rhit:hover,.rhit[aria-selected=true]{background:rgba(255,139,43,.08)} +.rtext{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +.rtext div{display:flex;gap:8px;align-items:baseline;min-width:0} +.rtext b{font-size:14px;flex-shrink:0} +.rtext div span{font-size:13.5px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rmeta{font-size:13px;color:var(--muted)} +.rbadge{flex-shrink:0;font-size:12.5px;font-weight:700;padding:2px 9px;border-radius:999px;background:color-mix(in srgb,var(--c,#8B929A) 18%,transparent);color:var(--c,var(--tx2))} +.rempty{margin:0;padding:14px;font-size:14px;color:var(--muted)} +.rhelp{margin-top:6px;padding:8px 14px 4px;border-top:1px solid var(--line);font-size:12.5px;color:var(--faint)} .tinydot{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted);white-space:nowrap} .tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)} .tinydot:empty{display:none} @@ -137,7 +154,14 @@ main{padding:0 24px 24px} #tab-quotes{padding-top:18px} .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{text-align:left;display:flex;align-items:center;gap:12px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%} +.qitem .qmain{flex:1;min-width:0} +.qitem .qmain>span{display:block} +.qsearch{display:flex;align-items:center;gap:8px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint)} +.qsearch:focus-within{border-color:var(--accent)} +.qsearch input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none} +.chips.small{gap:6px} +.chips.small .chip{height:30px;padding:0 10px;font-size:13px} .qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15} .qitem b{display:block;font-size:14.5px;overflow-wrap:anywhere} .qitem span{font-size:13.5px;color:var(--muted)} @@ -196,7 +220,9 @@ table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:to @media (max-width:900px){ .top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px} - .search{order:5;width:100%;margin-left:0} + .searchbox{order:5;width:100%;margin-left:0} + .search{width:100%} + .results{width:100%} main{padding:0 16px 16px} .split{flex-direction:column} .split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)} @@ -207,3 +233,28 @@ table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:to .btn.with-icon{display:inline-flex;align-items:center;gap:6px} .card .foot .where{display:inline-flex;align-items:center;gap:4px} a.zap{display:inline-flex;align-items:center;gap:4px;vertical-align:bottom} + +/* Artwork pictures, made by the customer's browser. The checkerboard shows + what is transparent; without a picture, the layout drawing stands in. */ +.art{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border-radius:8px;border:1px solid var(--line2); + background-color:#2A2F35;background-image:conic-gradient(#323840 25%,transparent 0 50%,#323840 0 75%,transparent 0);background-size:12px 12px} +.art img{display:block;max-width:100%;max-height:100%;object-fit:contain} +.art.layout{background:var(--card2);padding:4px} +.art.layout svg{display:block;height:100%;width:auto;max-width:100%} +.art.none{background:var(--card2)} +.art .noart{font-size:12px;font-weight:700;color:var(--faint)} +.card .card-art{width:100%;height:96px} +.qart{width:56px;height:56px} +.rart{width:44px;height:44px;background-size:8px 8px} +.item .preview.with-art{width:220px;padding:8px;flex-direction:column;align-items:stretch;gap:8px} +.pane .item .preview.with-art{width:320px} +.ptabs{display:flex;gap:4px} +.ptabs button{height:28px;padding:0 10px;border-radius:7px;border:0;background:transparent;color:var(--muted);font-size:12.5px;font-weight:600} +.ptabs button[aria-pressed=true]{background:var(--card);color:var(--tx);font-weight:700} +.preview .view{height:220px;border-radius:8px;display:flex;gap:6px;align-items:center;justify-content:center;overflow:hidden} +.pane .preview .view{height:300px} +.preview .view.art{padding:6px;border:1px solid var(--line2); + background-color:#2A2F35;background-image:conic-gradient(#323840 25%,transparent 0 50%,#323840 0 75%,transparent 0);background-size:14px 14px} +.preview .view.art img{flex:1;min-width:0;max-height:100%;object-fit:contain} +.preview .view.layout{padding:6px;background:var(--bg)} +.item .preview .view.layout svg{height:100%;width:auto;max-width:100%} diff --git a/web/kanban.html b/web/kanban.html index 1ad6bc6..a190898 100644 --- a/web/kanban.html +++ b/web/kanban.html @@ -22,10 +22,14 @@ - + @@ -42,6 +46,11 @@

Cotações

+ +
diff --git a/web/kanban.js b/web/kanban.js index c50650a..a20f7ea 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -27,8 +27,13 @@ function outcomeText(outcome){ } let board=null; -let tab='board', filter='all', search='', openOrder=null, openToken=0; +let tab='board', filter='all', openOrder=null, openToken=0; let quoteKind='pending', pickedQuote=null; +let quoteQuery='', quoteProduct='', quoteLayout='', quoteFlag='', quoteTimer=null; +// Uploads that have an artwork picture (the customer's browser made it). Every +// list the API returns says which, so only pictures that exist are asked for. +const THUMBS=new Set(); +const absorb=r=>{for(const id of r?.thumbnails||[])THUMBS.add(String(id));return r;}; let olderFinished=[], finishedMore=null; const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); @@ -102,11 +107,52 @@ function manifest(production){ link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); } +// ---- Artwork pictures ------------------------------------------------------- + +// The picture of one upload, or null when the browser could not make one (a +// CDR, a PSD, an order from before pictures existed). +function artPicture(uid,alt=''){ + if(!uid||!THUMBS.has(String(uid)))return null; + const img=node('img');img.src='/api/operator/uploads/'+uid+'/thumbnail';img.alt=alt;img.loading='lazy';img.decoding='async'; + img.onerror=()=>{THUMBS.delete(String(uid));img.remove();}; + return img; +} +// A small box with the first picture of these items; without one, the layout +// drawing, as before pictures existed. +function artBox(items,className){ + const box=node('div',undefined,'art '+className); + const uid=items.flatMap(i=>i.uploads||[]).find(u=>THUMBS.has(String(u))); + const pic=artPicture(uid); + if(pic){box.append(pic);return box;} + const item=items.find(i=>i.production?.placements); + if(item){box.classList.add('layout');box.append(layoutPreview(item.production));} + else{box.classList.add('none');box.append(node('span','sem prévia','noart'));} + return box; +} +// An item's artwork (each file's picture) and its layout on the film, as two views. +function itemPreview(item){ + const wrap=node('div',undefined,'preview'); + const pics=(item.uploads||[]).map((uid,i)=>artPicture(uid,'Arte '+(i+1))).filter(Boolean); + const layout=item.production?.placements?layoutPreview(item.production):null; + if(!pics.length){if(layout)wrap.append(layout);return wrap;} + wrap.classList.add('with-art'); + const art=node('div',undefined,'view art');art.append(...pics); + if(!layout){wrap.append(art);return wrap;} + const film=node('div',undefined,'view layout');film.append(layout);film.hidden=true; + const tabs=node('div',undefined,'ptabs'); + const pick=(show)=>{art.hidden=show!=='art';film.hidden=show!=='film'; + for(const b of tabs.children)b.setAttribute('aria-pressed',String(b.dataset.view===show));}; + for(const [view,label] of [['art','Arte'],['film','Montagem no filme']]){ + const b=node('button',label);b.type='button';b.dataset.view=view;b.setAttribute('aria-pressed',String(view==='art')); + b.onclick=e=>{e.stopPropagation();pick(view);};tabs.append(b);} + wrap.append(tabs,art,film);return wrap; +} + // ---- Loading and tabs ------------------------------------------------------ async function load(){ try{ - board=await api('/board'); + board=absorb(await api('/board')); const known=new Set(board.orders.map(o=>o.id)); board.orders.push(...olderFinished.filter(o=>!known.has(o.id))); if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown; @@ -153,19 +199,20 @@ function printState(order){ return {text:items>1&&ready?ready+' de '+items+' pronto':'Preparar à mão',tone:'warn'}; return {text:'Sem arquivo gerado',tone:'warn'}; } +const stalled=order=>['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000; 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==='entrega'&&!order.snapshot.destination)return false; + if(filter==='retirada'&&order.snapshot.destination)return false; + if(filter==='parados'&&!stalled(order))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)); + return true; } function renderBoard(){ - const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['manual','Preparar à mão']]; + const filters=[['all','Todos'],['textil','Têxtil'],['uv','UV'],['entrega','Entrega'],['retirada','Retirada'], + ['parados','Parados há '+LATE_HOURS+' h'],['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;})); @@ -184,7 +231,7 @@ function renderBoard(){ head.append(line,node('small',orders.length?metres(total)+' no total':'vazio')); column.append(head); for(const order of orders)column.append(card(order)); - if(state==='fin'&&finishedMore&&!search&&filter==='all')moreButton(column,true,loadOlderFinished); + if(state==='fin'&&finishedMore&&filter==='all')moreButton(column,true,loadOlderFinished); // Dragging only goes forward to the next stage. Going back or asking for a // correction needs a reason, so those happen in the order panel. column.ondragover=e=>{if(!column.classList.contains('allowed'))return;e.preventDefault();column.classList.add('target');}; @@ -210,9 +257,8 @@ function card(order){ const dest=order.snapshot.destination; const where=node('span',dest?'Entrega · '+dest.state:'Retirada','where');where.prepend(icon(dest?ICON_TRUCK:ICON_PIN)); foot.append(status,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','late')); + c.append(artBox(order.snapshot.items,'card-art'),top,node('div',order.snapshot.customer.mail,'cust'),tags,foot); + if(stalled(order))c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late')); c.onclick=()=>openPanel(order.id); c.ondragstart=e=>{ e.dataTransfer.setData('text/plain',order.id); @@ -227,7 +273,7 @@ async function loadOlderFinished(){ const shown=board.orders.filter(o=>o.state==='fin').sort((a,b)=>a.created_ato.id)); const fresh=page.orders.filter(o=>!known.has(o.id)); olderFinished.push(...fresh);board.orders.push(...fresh);finishedMore=page.has_more;renderBoard(); @@ -326,8 +372,7 @@ 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 preview=itemPreview(item); 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; @@ -419,7 +464,9 @@ function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Numbe let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0; async function loadQuotes(){ const token=++quoteLoading; - const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)})); + const params=new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)}); + for(const [key,value] of [['q',quoteQuery],['product',quoteProduct],['layout',quoteLayout],['flag',quoteFlag]])if(value)params.set(key,value); + const page=absorb(await api('/quotes?'+params)); if(token!==quoteLoading)return; quoteRows=page.quotes;quoteTotal=page.total; if(quotePage>1&&!quoteRows.length&"eTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();} @@ -430,11 +477,20 @@ function renderQuotes(){ $('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;quotePage=1;currentQuote=null;loadQuotes().catch(e=>say(e.message,true));};return b;})); + // Product, layout and warning: each chip toggles; the two of a pair exclude each other. + const toggles=[['product','textil','Têxtil'],['product','uv','UV'],['layout','folha','Folha montada'], + ['layout','avulsa','Artes avulsas'],['flag','ressalva','Com ressalva']]; + const value={product:quoteProduct,layout:quoteLayout,flag:quoteFlag}; + $('quote-chips').replaceChildren(...toggles.map(([group,key,label])=>{ + const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(value[group]===key)); + b.onclick=()=>{const next=value[group]===key?'':key; + if(group==='product')quoteProduct=next;else if(group==='layout')quoteLayout=next;else quoteFlag=next; + quotePage=1;loadQuotes().catch(e=>say(e.message,true));};return b;})); if(!currentQuote||(!quoteRows.some(q=>q.id===currentQuote.id)&¤tQuote.status==='pending_review'))currentQuote=quoteRows[0]||null; else currentQuote=quoteRows.find(q=>q.id===currentQuote.id)||currentQuote; const list=quoteRows.map(q=>{ const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===currentQuote?.id)); - const left=node('div');left.append(node('b',q.draft.customer.mail), + const left=node('div',undefined,'qmain');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')); @@ -442,10 +498,12 @@ function renderQuotes(){ else if(q.review_reason)left.append(node('span','Revisão manual: '+q.review_reason,'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=()=>{currentQuote=q;renderQuotes();};return b; + b.append(artBox(q.draft.items,'qart'),left,right);b.onclick=()=>{currentQuote=q;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(quoteTotal>quoteSize||quotePage>1)list.push(pager({page:quotePage,size:quoteSize,total:quoteTotal,noun:'cotações',compact:true, + const filtered=quoteQuery||quoteProduct||quoteLayout||quoteFlag; + if(!list.length)list.push(node('p',filtered?'Nenhuma cotação com esses filtros.': + quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint')); + if(quoteTotal)list.push(pager({page:quotePage,size:quoteSize,total:quoteTotal,noun:'cotações',compact:true, onPage:n=>{quotePage=n;loadQuotes().catch(e=>say(e.message,true));}, onSize:n=>{quoteSize=n;quotePage=1;loadQuotes().catch(e=>say(e.message,true));}})); $('quote-list').replaceChildren(...list); @@ -496,8 +554,8 @@ function review(quote){ } 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 preview=itemPreview(item); + if(!preview.classList.contains('with-art'))preview.style.width='160px'; 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'; @@ -729,7 +787,78 @@ function renderIntegrations(){ // ---- 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');}; +$('quote-q').oninput=e=>{clearTimeout(quoteTimer);quoteTimer=setTimeout(()=>{ + quoteQuery=e.target.value.trim();quotePage=1;loadQuotes().catch(err=>say(err.message,true));},300);}; + +// ---- Search: orders (any stage) and unpaid quotes --------------------------- + +let searchTimer=null, searchToken=0, searchHits=[], searchAt=-1; +function closeSearch(){$('search-results').hidden=true;$('search').setAttribute('aria-expanded','false');searchAt=-1;} +async function runSearch(text){ + const token=++searchToken; + const result=absorb(await api('/search?'+new URLSearchParams({q:text}))); + if(token!==searchToken||$('search').value.trim()!==text)return; + const box=$('search-results');searchHits=[];searchAt=-1; + const parts=[]; + for(const [title,rows,make] of [['Pedidos',result.orders,orderHit],['Cotações',result.quotes,quoteHit]]){ + if(!rows.length)continue; + parts.push(node('div',title+' · '+rows.length,'rgroup')); + for(const row of rows){const hit=make(row);hit.el.id='hit-'+searchHits.length;searchHits.push(hit);parts.push(hit.el);} + } + if(!searchHits.length)parts.push(node('p','Nada encontrado para “'+text+'”.','rempty')); + parts.push(node('div','↑ ↓ para escolher · Enter abre · Esc fecha · busca por e-mail, CNPJ, WhatsApp ou nº do pedido','rhelp')); + box.replaceChildren(...parts);box.hidden=false;$('search').setAttribute('aria-expanded','true'); +} +function hitRow(items,title,sub,meta,badge,color,open){ + const el=node('button',undefined,'rhit');el.type='button';el.setAttribute('role','option');el.setAttribute('aria-selected','false'); + const text=node('div',undefined,'rtext');const line=node('div');line.append(node('b',title),node('span',sub)); + text.append(line,node('span',meta,'rmeta')); + const tag=node('span',badge,'rbadge');if(color)tag.style.setProperty('--c',color); + el.append(artBox(items,'rart'),text,tag); + el.onmousedown=e=>e.preventDefault(); + el.onclick=()=>{closeSearch();open();}; + return {el,open}; +} +function orderHit(order){ + const s=order.snapshot; + return hitRow(s.items,'#'+order.number,s.customer.mail, + s.items.map(i=>SHORT[i.mode]+' · '+metres(i.billed_metres)).join(' + ')+' · '+(s.destination?'Entrega':'Retirada')+' · pago '+when(order.created_at), + board.states[order.state],STAGE_COLORS[order.state],()=>{ + // An older finished order is not on the board yet: it joins, like "Carregar mais". + if(!board.orders.some(o=>o.id===order.id)){olderFinished.push(order);board.orders.push(order);} + showTab('board');openPanel(order.id);}); +} +function quoteHit(q){ + return hitRow(q.draft.items,q.approved?money(q.approved.total_cents):'Cotação '+q.id.slice(0,8),q.draft.customer.mail, + q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))+' · '+ago(q.created_at), + q.approved?'Aguardando pagamento':'A revisar',q.approved?'':'var(--accent)',()=>{ + // Opens Cotações filtered to this customer, with this quote selected. + quoteKind=q.approved?'approved':'pending';quoteQuery=q.draft.customer.mail;$('quote-q').value=quoteQuery; + quoteProduct=quoteLayout=quoteFlag='';quotePage=1;currentQuote=q;showTab('quotes');}); +} +$('search').oninput=e=>{ + const text=e.target.value.trim();clearTimeout(searchTimer); + if(text.length<2){searchToken++;closeSearch();return;} + searchTimer=setTimeout(()=>runSearch(text).catch(err=>say(err.message,true)),250); +}; +$('search').onkeydown=e=>{ + if(e.key==='Escape'){e.stopPropagation();closeSearch();return;} + if($('search-results').hidden||!searchHits.length)return; + if(e.key==='ArrowDown'||e.key==='ArrowUp'){ + e.preventDefault();searchAt=(searchAt+(e.key==='ArrowDown'?1:-1)+searchHits.length)%searchHits.length; + searchHits.forEach((h,i)=>h.el.setAttribute('aria-selected',String(i===searchAt))); + $('search').setAttribute('aria-activedescendant',searchHits[searchAt].el.id);searchHits[searchAt].el.scrollIntoView({block:'nearest'}); + } + if(e.key==='Enter'){e.preventDefault();const hit=searchHits[Math.max(0,searchAt)];closeSearch();hit.open();} +}; +$('search').onblur=()=>setTimeout(closeSearch,150); +$('search').onfocus=()=>{if($('search').value.trim().length>=2&&searchHits.length){$('search-results').hidden=false;$('search').setAttribute('aria-expanded','true');}}; +// "/" jumps to the search, as the field shows, unless typing somewhere else. +document.addEventListener('keydown',e=>{ + if(e.key!=='/'||e.ctrlKey||e.metaKey||e.altKey)return; + const t=e.target;if(t.closest&&t.closest('input,textarea,select,[contenteditable="true"]'))return; + if($('app').hidden)return;e.preventDefault();$('search').focus(); +}); $('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; diff --git a/web/site-cart.js b/web/site-cart.js index 38714ab..85d4341 100644 --- a/web/site-cart.js +++ b/web/site-cart.js @@ -127,6 +127,7 @@ function carrinho(){ itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot, localFiles:(ehFolha()?folhas:artes).map(x=>x.f), mini:(ehFolha()?folhas:artes).map(x=>x.miniSrc).find(Boolean)||null, + fotos:(ehFolha()?folhas:artes).map(x=>x.fotoSrc||null), ext:extDe(((ehFolha()?folhas:artes)[0]||{f:{name:''}}).f.name), arquivos:(ehFolha()?folhas:artes).length, production:{version:2,film_width_cm:larguraFilme(),sources,...layout}, diff --git a/web/site-pdf.js b/web/site-pdf.js index 18e8ade..8d445a9 100644 --- a/web/site-pdf.js +++ b/web/site-pdf.js @@ -357,10 +357,11 @@ function textoProgresso(x){ return x.pct<50?'lendo o arquivo…':x.pct<70?'medindo a folha…':'conferindo a arte…'; } // The file card's thumbnail (52 x 96 px, drawn at twice that for sharp screens). -function miniatura(fonte){ +// At 480 x 720 it is the picture the Kanban shows of the order (checkout.js). +function miniatura(fonte, maxW=104, maxH=192){ const w=fonte.naturalWidth||fonte.width, h=fonte.naturalHeight||fonte.height; if(!(w>0 && h>0)) return null; - const s=Math.min(1,104/w,192/h), cv=document.createElement('canvas'); + const s=Math.min(1,maxW/w,maxH/h), cv=document.createElement('canvas'); cv.width=Math.max(1,Math.round(w*s)); cv.height=Math.max(1,Math.round(h*s)); cv.getContext('2d').drawImage(fonte,0,0,cv.width,cv.height); return cv.toDataURL('image/webp',0.8); diff --git a/web/site-upload.js b/web/site-upload.js index 7d3af72..a7e9603 100644 --- a/web/site-upload.js +++ b/web/site-upload.js @@ -73,7 +73,7 @@ function sel(fs){ if(pct-mostrado>=1){ mostrado=pct; x.pct=pct; pintaProgressoFolha(x); } }).then(cv=>{ if(cv && folhas.includes(x)){ - x.previewSrc=cv.toDataURL('image/webp',0.85); x.miniSrc=miniatura(cv); + x.previewSrc=cv.toDataURL('image/webp',0.85); x.miniSrc=miniatura(cv); x.fotoSrc=miniatura(cv,480,720); try{ const an=analisarFolha(cv, md.larg); x.an={...an, dpi:md.dpiFolha, fonteDpi:'arquivo', grande:false}; @@ -82,7 +82,7 @@ function sel(fs){ }).catch(()=>{}).finally(()=>{ x.pct=null; pintaFolha(); }); }else if(md && RENDERIZA.test(x.f.name)){ carregarImagem(x.f).then(img=>{ - if(img){ x.previewSrc=img.src; x.miniSrc=miniatura(img); try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; } + if(img){ x.previewSrc=img.src; x.miniSrc=miniatura(img); x.fotoSrc=miniatura(img,480,720); try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; } catch(e){} } x.pct=null; pintaFolha(); }); @@ -98,7 +98,7 @@ function sel(fs){ : ''); } if(r && r.tela){ - x.miniSrc=miniatura(r.tela); + x.miniSrc=miniatura(r.tela); x.fotoSrc=miniatura(r.tela,480,720); try{ x.an=analisarFolha(r.tela, md.larg); // vetor puro não tem resolução · nota máxima, e está certo @@ -140,13 +140,13 @@ function medir(a){ if(pct-mostrado>=1){ mostrado=pct; a.lendo=pct; pintaProgressoArte(a); } }).catch(()=>null); a.lendo=null; - if(cv && artes.includes(a)){ a.src=cv.toDataURL('image/webp',0.9); a.miniSrc=miniatura(cv); } + if(cv && artes.includes(a)){ a.src=cv.toDataURL('image/webp',0.9); a.miniSrc=miniatura(cv); a.fotoSrc=miniatura(cv,480,720); } else if(!cv) a.decodeError=true; return a; }).catch(()=>{ a.decodeError=true; return a; }); } return carregarImagem(a.f).then(img=>{ - if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; a.miniSrc=miniatura(img); } + if(img){ a.prop=img.height/img.width; a.px=img.width; a.py=img.height; a.src=img.src; a.miniSrc=miniatura(img); a.fotoSrc=miniatura(img,480,720); } else a.decodeError=true; return a; });