feat: Kanban shows each order's artwork, filters quotes and searches orders and quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s

The customer's browser sends the small picture it already makes of each file
(at most 300 KB, WebP/JPEG/PNG read from the bytes, own uploads only); it goes
when the file's bytes go. Board cards, quote rows, the order panel and the
quote detail show it, with the layout drawing as a second view and as the
fallback for files without a picture.

Cotações gets a customer filter (e-mail, CNPJ, WhatsApp) and product, layout
and resolution-warning chips, with the page bar always shown. The board adds
Entrega, Retirada and stalled-order chips. The top search now covers orders in
any stage and unpaid quotes; CNPJ and phone match by digits only when nothing
but digits and punctuation was typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-10-01 11:46:33 -03:00
parent b54c635cfc
commit 352590e63a
15 changed files with 547 additions and 52 deletions

View File

@@ -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 \

View File

@@ -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:

View File

@@ -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}

View File

@@ -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

View File

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

View File

@@ -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<now()-interval '1 day'")

View File

@@ -167,6 +167,14 @@ The workflow test also checks guest-to-account migration, cross-session login,
revoked sessions, ownership isolation, correction submissions, final revision
invalidation, secure downloads, and mandatory final-file approval before queueing.
The Kanban's artwork pictures, quote filters and order/quote search have their
own check, run inside the API container because it writes fixtures to the
database (and removes them):
```bash
docker compose -f compose.local.yaml exec -T -e SITE_BASE_URL=http://site -e SITE_HOST_HEADER=localhost api python -m tests.kanban_test
```
Security and malware regressions are separate so an authorized harmless EICAR
test is unmistakable:

126
tests/kanban_test.py Normal file
View File

@@ -0,0 +1,126 @@
"""Kanban artwork pictures, quote filters and the search over orders and quotes.
Run against the local stack: python3 -m tests.kanban_test
"""
import base64
import secrets
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request
from uuid import uuid4
from psycopg.types.json import Jsonb
from app.core import db
from tests.smoke_test import BASE, Client, with_host
# A 1 x 1 PNG: the endpoint reads the type from the bytes.
PNG = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==')
def raw(client, method, path, data=None, content_type=None):
"""A request whose body is not JSON; returns (status, headers, body)."""
headers = with_host({'Content-Type': content_type} if content_type else {})
request = Request(BASE + '/api' + path, data=data, headers=headers, method=method)
try:
with client.opener.open(request, timeout=30) as response:
return response.status, response.headers, response.read()
except HTTPError as exc:
return exc.code, exc.headers, exc.read()
def session_upload(client, name):
"""A completed, clean upload owned by this client's session. The bytes are
not the subject here, so none are stored."""
sid = next(c.value for c in client.jar if c.name == 'dtf_session')
uid = uuid4()
with db.connect() as c:
owner = c.execute('SELECT owner FROM dtf_local.sessions WHERE id=%s', (sid,)).fetchone()['owner']
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,expires_at,scan_state)
VALUES(%s,%s,%s,1,%s,'none',true,now()+interval '1 day','clean')''', (uid, owner, name, f'originals/{uid}'))
return str(uid)
def run():
tag = secrets.token_hex(4)
mail = f'kanban-{tag}@example.test'
cnpj = '11.222.333/0001-81'
customer = {'mail': mail, 'cnpj': cnpj, 'zap': '(16) 98765-' + str(4000 + int(tag[:3], 16) % 1000)}
client = Client()
client.call('/session')
uid = session_upload(client, f'kanban-{tag}.cdr')
# The customer's browser sends the picture of its own upload; nothing else.
assert raw(client, 'PUT', f'/uploads/{uid}/thumbnail', PNG, 'image/png')[0] == 200
assert raw(client, 'PUT', f'/uploads/{uid}/thumbnail', b'<svg onload=alert(1)>', '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()

View File

@@ -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,

View File

@@ -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%}

View File

@@ -22,10 +22,14 @@
<button type="button" data-tab="payments" aria-selected="false">Pagamentos <span class="badge warn" id="badge-payments"></span></button>
<button type="button" data-tab="integrations" aria-selected="false">Integrações</button>
</nav>
<div class="searchbox">
<label class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
<input id="search" type="search" placeholder="Buscar pedido, cliente ou CNPJ" aria-label="Buscar pedido, cliente ou CNPJ">
<input id="search" type="search" placeholder="Buscar em pedidos e cotações: cliente, CNPJ, nº" aria-label="Buscar em pedidos e cotações" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="search-results">
<kbd>/</kbd>
</label>
<div id="search-results" class="results" role="listbox" aria-label="Resultados da busca" hidden></div>
</div>
<span id="tiny" class="tinydot"></span>
<span id="who" class="who"></span>
<button class="btn ghost" id="refresh" type="button">Atualizar</button>
@@ -42,6 +46,11 @@
<div class="list">
<h1>Cotações</h1>
<div class="chips" id="quote-filters"></div>
<label class="qsearch">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.5-3.5"></path></svg>
<input id="quote-q" type="search" placeholder="Filtrar por e-mail, CNPJ ou WhatsApp" aria-label="Filtrar cotações por cliente" autocomplete="off">
</label>
<div class="chips small" id="quote-chips"></div>
<div id="quote-list" style="display:flex;flex-direction:column;gap:8px"></div>
</div>
<div class="pane" id="reviews"></div>

View File

@@ -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_at<b.created_at?-1:1);
const oldest=shown[0];const params=new URLSearchParams({limit:'50'});
if(oldest){params.set('before_created_at',oldest.created_at);params.set('before_id',oldest.id);}
const page=await api('/orders/finished?'+params);
const page=absorb(await api('/orders/finished?'+params));
const known=new Set(board.orders.map(o=>o.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&&quoteTotal){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)&&currentQuote.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;

View File

@@ -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},

View File

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

View File

@@ -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;
});