feat: undo mistaken moves, numbered pagination, and quieter Kanban messages
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s

Moves: an order can go back one stage (BACK in app/runtime.py) with an
internal reason, flagged in the history as movements.back. The customer is
not notified and approved finals stay; "production started" and "ready" are
now enqueued once per order, so undoing and redoing a move sends nothing
twice. Dragging only goes forward and highlights the allowed column. Move
errors are in Portuguese.

Lists: the send log, payments (open, resolved as history, all) and quotes
are paged on the server with a total, 20 rows by default (10/20/50/100),
first/previous/page/next/last. The send log filters by destination, status,
event and order. Older finished orders load on demand. The board no longer
carries the send log or payment rows, only the open-payment count.

Kanban: Pagamentos and Integrações are separate tabs; messages are brief,
bottom notifications that clear themselves; wording is shorter.

Full CI integration sequence passes locally, with new checks for undo, paging
and filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-24 16:01:31 -03:00
parent 99a558ddd9
commit 2e03b0362b
12 changed files with 434 additions and 127 deletions

View File

@@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review
from ..core.pricing import price from ..core.pricing import price
from ..printjobs import queue as queue_print_files from ..printjobs import queue as queue_print_files
from .. import tiny from .. import tiny
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment, from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage, upload_row) enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean from ..scanning import require_clean
@@ -84,42 +84,112 @@ def board(user=Depends(operator)):
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n'] WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
orders = active + list(reversed(finished)) orders = with_print_files(c, active + list(reversed(finished)))
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
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
# A paid notification that did not become an order is money received # A paid notification that did not become an order is money received
# for nothing the factory will make. It stays on the board until a # for nothing the factory will make. The board carries the count; the
# person records what was done about it. # Pagamentos tab pages through them until someone records a resolution.
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n']
ORDER BY received_at LIMIT 100''').fetchall() return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
return {'states': STATES, 'transitions': TRANSITIONS, 'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name}, 'environment': ENVIRONMENT, 'providers': {'payment': payment.name}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total, 'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved], 'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total, 'pending_total': pending_total, 'approved_total': approved_total}
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
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
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
for order in orders:
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
return orders
def cursor_pair(first, second):
if (first is None) != (second is None):
raise HTTPException(422, 'Both cursor fields are required')
return first is not None
@router.get('/api/operator/orders/finished')
def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None,
limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)):
"""Older finished orders, newest first, beyond the board's recent window."""
paged = cursor_pair(before_created_at, before_id)
with db.connect() as c:
rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin'
''' + ('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}
@router.get('/api/operator/payment-events')
def payment_events(state: Literal['open','resolved','all'] = 'open',
offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""Payments that needed a person, newest first: open ones to act on, resolved ones as history."""
where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state]
with db.connect() as c:
rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome,
resolved_at,resolved_by,resolution
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + '''
ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall()
total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n']
return {'issues': rows, 'total': total}
@router.get('/api/operator/events')
def events(provider: Literal['tiny','whatsapp'] | None = None,
status: Literal['delivered','queued','failing'] | None = None,
event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None,
order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0),
limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)):
"""The integration send log, newest first, filtered, by page with a total."""
clauses, params = [], []
if provider:
clauses.append('provider=%s'); params.append(provider)
if status == 'delivered':
clauses.append('delivered_at IS NOT NULL')
elif status == 'queued':
clauses.append('delivered_at IS NULL AND last_error IS NULL')
elif status == 'failing':
clauses.append('delivered_at IS NULL AND last_error IS NOT NULL')
if event:
clauses.append("payload->>'event'=%s"); params.append(event)
if order:
clauses.append("payload->>'number'=%s"); params.append(str(order))
where = ('WHERE ' + ' AND '.join(clauses)) if clauses else ''
with db.connect() as c:
rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s',
(*params, limit, offset)).fetchall()
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
return {'events': rows, 'total': total}
@router.get('/api/operator/quotes') @router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None, def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100), before_id: UUID | None = None, offset: int = Query(default=0, ge=0),
user=Depends(operator)): 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."""
if (before_created_at is None) != (before_id is None): if (before_created_at is None) != (before_id is None):
raise HTTPException(422, 'Both quote cursor fields are required') raise HTTPException(422, 'Both quote cursor fields are required')
approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL' approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL'
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else '' cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,) skip = 0 if before_created_at else offset
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
with db.connect() as c: with db.connect() as c:
rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
WHERE o.id IS NULL AND {approved_filter} {cursor} WHERE o.id IS NULL AND {approved_filter} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall() ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).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]], return {'quotes':[quote_view(c,row) for row in rows[:limit]],
'has_more':len(rows)>limit} 'has_more':len(rows)>limit, 'total': total}
@router.post('/api/operator/quotes/{uid}/approve') @router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)): def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -160,26 +230,34 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
if not row: if not row:
raise HTTPException(404, 'Order not found') raise HTTPException(404, 'Order not found')
if body.version != row['version']: if body.version != row['version']:
raise HTTPException(409, 'Order changed; refresh the board') raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.')
if body.state == row['state']: if body.state == row['state']:
return row return row
if body.state not in TRANSITIONS[row['state']]: back = BACK.get(row['state']) == body.state
raise HTTPException(409, 'Move is not allowed from this state') if body.state not in TRANSITIONS[row['state']] and not back:
raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.")
if body.state == 'cor' and not body.reason.strip(): if body.state == 'cor' and not body.reason.strip():
raise HTTPException(422, 'Correction requires a reason') raise HTTPException(422, 'Informe o motivo da correção.')
if back and not body.reason.strip():
raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.')
if body.state in ('fil','imp'): if body.state in ('fil','imp'):
coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall() coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall()
if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))): if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))):
raise HTTPException(409, 'Approve a complete final-file set for every item before queueing') raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.')
if body.state == 'cor': if body.state == 'cor':
c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,)) c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,))
c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)', c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)',
(uid,row['state'],body.state,user,body.reason)) (uid,row['state'],body.state,user,body.reason,back))
changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone() changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone()
events = {'imp':'production_started','cor':'correction_needed','fin':'ready'} events = {'imp':'production_started','cor':'correction_needed','fin':'ready'}
if body.state in events: if body.state in events and not back:
# "Production started" and "ready" reach the customer once per order,
# even if a mistaken move is undone and made again. Each correction
# is a new request, so it keeps one message per movement.
once = body.state in ('imp','fin')
for provider in ('tiny','whatsapp'): for provider in ('tiny','whatsapp'):
enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider, key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}'
enqueue(c, key, provider,
{'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason, {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason,
'customer_path': f'/portal.html?order={uid}'}) 'customer_path': f'/portal.html?order={uid}'})
return changed return changed

View File

@@ -46,6 +46,9 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress
'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'} 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'], TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []} 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
# Undoing a move made by mistake: one stage back, with an internal reason. It
# is not a correction: the customer is not told and approved finals stay.
BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'}
def require_delivery_available(service): def require_delivery_available(service):

View File

@@ -173,6 +173,9 @@ CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_event
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at) CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
WHERE status IN ('pending','rendering'); WHERE status IN ('pending','rendering');
-- A movement that undid an earlier one (a mistaken move), shown as such.
ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false;
-- The Kanban lists payment events a person must act on (money without an -- The Kanban lists payment events a person must act on (money without an
-- order, or a reversed payment on an existing order) until resolved. -- order, or a reversed payment on an existing order) until resolved.
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz; ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;

View File

@@ -80,10 +80,15 @@ operator interfaces.
preselected instead (see "Print files"). Continue with the panel's main preselected instead (see "Print files"). Continue with the panel's main
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
correção** asks for the reason the customer will see; from **Correção** the correção** asks for the reason the customer will see; from **Correção** the
order returns to **Arte recebida** or **Arte tratada**. order returns to **Arte recebida** or **Arte tratada**. A move made by
8. The panel's **Histórico** lists every move. The **Pagamentos e mistake is undone with **Voltar para …**: one stage back, with an internal
integrações** tab shows the Tiny connection, payments that need a person, reason recorded in the history. The customer is not notified, approved
and the send log; with the fake adapters every send reads "Registrado finals stay, and "produção iniciada"/"pedido pronto" are sent only once per
order even if the stage is entered again. Dragging a card only goes forward;
the columns it can be dropped on are highlighted.
8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists
payments that need a person; **Integrações** shows the Tiny connection and
the send log; with the fake adapters every send reads "Registrado
(simulado)". Download links expire after five minutes; click again for a new (simulado)". Download links expire after five minutes; click again for a new
one. The previous Kanban is kept in git tag `ui-v1`. one. The previous Kanban is kept in git tag `ui-v1`.

View File

@@ -287,6 +287,11 @@ download, approve as final, queue; hand-preparation routing and retry).
print-file status per item, and a panel lists payments that need a person print-file status per item, and a panel lists payments that need a person
(money without an order, refunds after an order) until resolved. Confirm (money without an order, refunds after an order) until resolved. Confirm
with the operation that these are the main states before closing. with the operation that these are the main states before closing.
**2026-09-24:** a mistaken move can be undone one stage back (`BACK` in
`app/runtime.py`) with an internal reason, flagged in the history
(`movements.back`), without notifying the customer; "production started" and
"ready" are enqueued once per order. Previously the only way back was
Correção, which messages the customer and invalidates approved finals.
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18. - `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`) `[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)

View File

@@ -138,6 +138,15 @@ try{
await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`); await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state); await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
} }
// Undo a mistaken move from the panel: one stage back with an internal reason.
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button');
await kanban.click(`[data-order="${oid}"] [data-back="imp"]`);
await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)');
await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo');
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again');
await kanban.click(`[data-order="${oid}"] [data-move="fin"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again');
// Reload proves the board is persisted on the backend. // Reload proves the board is persisted on the backend.
await kanban.call('Page.reload'); await kanban.call('Page.reload');
await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board'); await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board');

View File

@@ -109,7 +109,7 @@ def run():
# The customer is told once, not once per delivery. # The customer is told once, not once per delivery.
board = Client() board = Client()
events = board.call('/operator/board', operator=True)['events'] events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events']
paid = [e for e in events if e['payload'].get('order_id') == order['id'] paid = [e for e in events if e['payload'].get('order_id') == order['id']
and e['payload'].get('event') == 'payment_approved'] and e['payload'].get('event') == 'payment_approved']
assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}' assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}'

View File

@@ -141,17 +141,43 @@ def run():
outcome = deliver({'event_id': event_id, 'reference': quote_id, outcome = deliver({'event_id': event_id, 'reference': quote_id,
'status': 'approved', 'amount_cents': total - 1}) 'status': 'approved', 'amount_cents': total - 1})
assert outcome['outcome'].startswith('refused'), outcome assert outcome['outcome'].startswith('refused'), outcome
issues = client.call('/operator/board', operator=True)['payment_issues'] issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
issue = next(i for i in issues if i['event_id'] == event_id) issue = next(i for i in issues if i['event_id'] == event_id)
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'}, client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422) operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True) {'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve', client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: second resolution'}, operator=True, expected=404) {'note': 'Local test: second resolution'}, operator=True, expected=404)
issues = client.call('/operator/board', operator=True)['payment_issues'] issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues']
assert not any(i['event_id'] == event_id for i in issues) assert not any(i['event_id'] == event_id for i in issues)
print('PASS: refused paid notification is listed until an operator resolves it') resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True)
assert resolved_page['total'] >= 1
resolved = resolved_page['issues']
done = next(i for i in resolved if i['event_id'] == event_id)
assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by']
print('PASS: refused paid notification is listed until an operator resolves it, then kept as history')
# The send log pages by id and filters by destination, status, event and order.
page = client.call('/operator/events?limit=2', operator=True)
assert len(page['events']) == 2 and page['total'] > 2
second = client.call('/operator/events?limit=2&offset=2', operator=True)
assert all(e['id'] < page['events'][-1]['id'] for e in second['events'])
assert second['total'] == page['total']
tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True)
assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved'
and e['delivered_at'] for e in tiny['events'])
client.call('/operator/events?provider=email', operator=True, expected=422)
client.call('/operator/events?limit=500', operator=True, expected=422)
quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True)
assert 'total' in quotes and len(quotes['quotes']) <= 1
finished = client.call('/operator/orders/finished?limit=1', operator=True)
if finished['orders']:
last = finished['orders'][-1]
client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B')
+ '&before_id=' + last['id'], operator=True)
print('PASS: send log, payment history and finished orders page and filter')
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -185,10 +185,21 @@ def run():
version+=1;assert moved['version']==version version+=1;assert moved['version']==version
client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409) client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409)
assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6 assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6
# A mistaken move can be undone one stage at a time, with an internal
# reason. The customer is not told again: going back and forward once more
# adds no messages (the outbox count below stays 8).
client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422)
client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409)
back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True)
assert back['state']=='imp';version+=1
client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1
history=client.call('/operator/orders/'+oid+'/history',operator=True)
assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back']
print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again')
print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history') print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history')
deadline=time.monotonic()+30 deadline=time.monotonic()+30
while time.monotonic()<deadline: while time.monotonic()<deadline:
events=[e for e in client.call('/operator/board',operator=True)['events'] if e['payload']['order_id']==oid] events=[e for e in client.call('/operator/events?order='+str(order['number']),operator=True)['events'] if e['payload']['order_id']==oid]
if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break if len(events)==8 and all(e['delivered_at'] and e['receipt'] for e in events):break
time.sleep(1) time.sleep(1)
else:raise AssertionError('Mock outbox did not drain') else:raise AssertionError('Mock outbox did not drain')

View File

@@ -57,17 +57,20 @@ input[type=file]{font-size:13px;color:var(--muted);max-width:100%}
.tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)} .tinydot::before{content:"";width:8px;height:8px;border-radius:999px;background:var(--dot,#5B6168)}
.tinydot:empty{display:none} .tinydot:empty{display:none}
.who{font-size:13px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis} .who{font-size:13px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#status{margin:0;padding:8px 24px 0;min-height:28px;font-size:13px;color:var(--muted)} #status{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:20;margin:0;max-width:min(640px,calc(100vw - 32px));padding:10px 16px;border-radius:10px;border:1px solid var(--line3);background:#1E2226;color:var(--tx);font-size:13px;box-shadow:0 10px 30px rgba(0,0,0,.45)}
#status.error,#login-status.error{color:var(--bad)} #status:empty,#login-status:empty{display:none}
#status.error{border-color:var(--bad-line);color:var(--bad)}
#login-status.error{color:var(--bad)}
/* Board */ /* Board */
main{padding:0 24px 24px} main{padding:0 24px 24px}
.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 0 12px} .bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:18px 0 12px}
.bar h1,.split h1,.page h1{margin:0;font-size:22px;font-weight:800;letter-spacing:-.02em} .bar h1,.split h1,.page h1{margin:0;font-size:22px;font-weight:800;letter-spacing:-.02em}
.chips{display:flex;gap:8px;flex-wrap:wrap} .chips{display:flex;gap:8px;flex-wrap:wrap}
.bar .chips{margin-left:auto} .bar .chips{margin-left:auto}
.kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start} .kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start}
.col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px} .col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px}
.col.allowed{border-color:rgba(255,139,43,.45);background:#17140F}
.col.target{border-color:var(--accent)} .col.target{border-color:var(--accent)}
.col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B} .col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B}
.col-head div{display:flex;align-items:center;gap:8px} .col-head div{display:flex;align-items:center;gap:8px}
@@ -130,6 +133,7 @@ main{padding:0 24px 24px}
.timeline small{display:block;color:var(--faint);font-size:12px} .timeline small{display:block;color:var(--faint);font-size:12px}
/* Quotes */ /* Quotes */
#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{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} .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;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
@@ -151,13 +155,30 @@ main{padding:0 24px 24px}
.pane .note{padding:24px;color:var(--muted)} .pane .note{padding:24px;color:var(--muted)}
/* Payments and integrations */ /* Payments and integrations */
.page{display:flex;flex-direction:column;gap:22px;padding-top:8px} .page{display:flex;flex-direction:column;gap:22px;padding-top:18px}
.page h2{margin:0 0 10px;font-size:15px;font-weight:800} .page h2{margin:0 0 10px;font-size:15px;font-weight:800}
.issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px} .issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px}
.issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px} .issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px}
.issue .text b{font-size:14px} .issue .text b{font-size:14px}
.issue .text span{font-size:13px;color:var(--muted);overflow-wrap:anywhere} .issue .text span{font-size:13px;color:var(--muted);overflow-wrap:anywhere}
.issue form{display:flex;gap:8px;align-items:center} .issue form{display:flex;gap:8px;align-items:center}
.issue.done{border-color:var(--line2);background:var(--card)}
.pager{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:14px 4px;border-top:1px solid var(--line);margin-top:8px;font-size:13px;color:var(--muted)}
.pager-size{display:flex;align-items:center;gap:8px}
.pager-size select{height:34px;padding:0 8px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.pager-range{margin-left:auto}
.pager-nav{display:flex;align-items:center;gap:6px}
.pager .pg{width:34px;height:34px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--tx);font-size:16px;line-height:1}
.pager .pg:disabled{color:var(--faint);opacity:.45}
.pager-page{display:flex;align-items:center;gap:6px;margin:0 4px}
.pager-page input{width:56px;height:34px;text-align:center;padding:0 4px}
.pager.compact{gap:10px;padding:12px 0 0}
.pager.compact .pager-range{margin-left:0}
.pager.compact .pager-nav{margin-left:auto}
.filters{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.field{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)}
.field select,.field input{height:38px;min-width:160px;padding:0 10px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
.field input{min-width:110px;width:110px}
.issue input{width:320px} .issue input{width:320px}
.grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} .grid4{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}
.integration{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--line2);background:var(--card)} .integration{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--line2);background:var(--card)}

View File

@@ -3,7 +3,7 @@
<title>Kanban DTF</title> <title>Kanban DTF</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<link rel="stylesheet" href="/kanban.css?v=redesign-2"> <link rel="stylesheet" href="/kanban.css?v=redesign-7">
</head><body> </head><body>
<form id="login" class="login" hidden> <form id="login" class="login" hidden>
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div> <div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
@@ -19,7 +19,8 @@
<nav class="tabs" aria-label="Seções"> <nav class="tabs" aria-label="Seções">
<button type="button" data-tab="board" aria-selected="true">Produção <span class="count" id="count-board"></span></button> <button type="button" data-tab="board" aria-selected="true">Produção <span class="count" id="count-board"></span></button>
<button type="button" data-tab="quotes" aria-selected="false">Cotações <span class="badge" id="badge-quotes"></span></button> <button type="button" data-tab="quotes" aria-selected="false">Cotações <span class="badge" id="badge-quotes"></span></button>
<button type="button" data-tab="integrations" aria-selected="false">Pagamentos e integrações <span class="badge warn" id="badge-payments"></span></button> <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> </nav>
<label class="search"> <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> <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>
@@ -46,12 +47,17 @@
<div class="pane" id="reviews"></div> <div class="pane" id="reviews"></div>
</div> </div>
</section> </section>
<section id="tab-integrations" class="page" hidden> <section id="tab-payments" class="page" hidden>
<h1>Pagamentos e integrações</h1> <h1>Pagamentos</h1>
<section id="payments"></section> <section id="payments"></section>
<section><h2>Integrações</h2><div class="grid4" id="integrations"></div></section> </section>
<section><h2>Registro de envios <span class="faint" style="font-weight:500;font-size:13px">últimos 100</span></h2> <section id="tab-integrations" class="page" hidden>
<h1>Integrações</h1>
<section><div class="grid4" id="integrations"></div></section>
<section><h2>Registro de envios</h2>
<div class="filters" id="event-filters"></div>
<table class="log"><thead><tr><th scope="col">Quando</th><th scope="col">Pedido</th><th scope="col">Destino</th><th scope="col">Evento</th><th scope="col">Resultado</th></tr></thead><tbody id="events"></tbody></table> <table class="log"><thead><tr><th scope="col">Quando</th><th scope="col">Pedido</th><th scope="col">Destino</th><th scope="col">Evento</th><th scope="col">Resultado</th></tr></thead><tbody id="events"></tbody></table>
<div id="events-more"></div>
</section> </section>
</section> </section>
</main> </main>
@@ -61,4 +67,4 @@
<div class="scrim" id="scrim"></div> <div class="scrim" id="scrim"></div>
<aside class="panel" id="panel" aria-label="Pedido"></aside> <aside class="panel" id="panel" aria-label="Pedido"></aside>
</div> </div>
<script src="/upload.js"></script><script src="/kanban.js?v=redesign-2"></script></body></html> <script src="/upload.js"></script><script src="/kanban.js?v=redesign-8"></script></body></html>

View File

@@ -29,7 +29,7 @@ function outcomeText(outcome){
let board=null; let board=null;
let tab='board', filter='all', search='', openOrder=null, openToken=0; let tab='board', filter='all', search='', openOrder=null, openToken=0;
let quoteKind='pending', pickedQuote=null; let quoteKind='pending', pickedQuote=null;
let extraQuotes={pending:[],approved:[]}, moreQuotes={pending:false,approved:false}; let olderFinished=[], finishedMore=null;
const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}); const money=cents=>(cents/100).toLocaleString('pt-BR',{style:'currency',currency:'BRL'});
const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m'; const metres=value=>Number(value).toLocaleString('pt-BR',{minimumFractionDigits:2,maximumFractionDigits:2})+' m';
@@ -46,8 +46,13 @@ function button(text,fn,className='btn'){
b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}}; b.onclick=async event=>{event.stopPropagation();b.disabled=true;try{await fn();}catch(e){say(e.message,true);}finally{b.disabled=false;}};
return b; return b;
} }
function say(text,error=false){ // Messages are notifications, not state: they clear themselves. Errors stay
// longer so they can be read; a caller can hold one for as long as it needs.
let sayTimer=null;
function say(text,error=false,ms){
clearTimeout(sayTimer);
for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);} for(const id of ['status','login-status']){$(id).textContent=text||'';$(id).classList.toggle('error',!!error);}
if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000));
} }
function icon(path){ function icon(path){
const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg'); const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg');
@@ -77,11 +82,11 @@ function manifest(production){
async function load(){ async function load(){
try{ try{
board=await api('/board'); board=await api('/board');
extraQuotes={pending:[],approved:[]}; const known=new Set(board.orders.map(o=>o.id));
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length, board.orders.push(...olderFinished.filter(o=>!known.has(o.id)));
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length}; if(finishedMore===null)finishedMore=board.finished_total>board.finished_shown;
$('login').hidden=true;$('app').hidden=false; $('login').hidden=true;$('app').hidden=false;
say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice); say('Atualizado às '+new Date().toLocaleTimeString('pt-BR')+tinyNotice,false,tinyNotice?6000:2500);
render(); render();
}catch(e){ }catch(e){
if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;} if(e.status===401||e.status===503){$('app').hidden=true;$('login').hidden=false;$('drawer').hidden=true;}
@@ -93,14 +98,19 @@ async function load(){
function showTab(name){ function showTab(name){
tab=name; tab=name;
for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name)); for(const b of document.querySelectorAll('[data-tab]'))b.setAttribute('aria-selected',String(b.dataset.tab===name));
$('tab-board').hidden=name!=='board';$('tab-quotes').hidden=name!=='quotes';$('tab-integrations').hidden=name!=='integrations'; for(const id of ['board','quotes','payments','integrations'])$('tab-'+id).hidden=name!==id;
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(board&&lists[name])lists[name]().catch(e=>say(e.message,true));
} }
function render(){ function render(){
$('who').textContent=board.operator||''; $('who').textContent=board.operator||'';
$('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length); $('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
$('badge-quotes').textContent=board.pending_total?String(board.pending_total):''; $('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
$('badge-payments').textContent=board.payment_issues?.length?String(board.payment_issues.length):''; $('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
tinyHeader();renderBoard();renderQuotes();renderIntegrations(); tinyHeader();renderBoard();renderIntegrations();
// Lists live on their own pages of the API; refresh the one being looked at.
const lists={quotes:loadQuotes,payments:loadIssues,integrations:loadEvents};
if(lists[tab])lists[tab]().catch(e=>say(e.message,true));
if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();} if(openOrder){const order=board.orders.find(o=>o.id===openOrder);if(order)renderPanel(order);else closePanel();}
} }
@@ -144,12 +154,15 @@ function renderBoard(){
const head=node('div',undefined,'col-head');const line=node('div'); const head=node('div',undefined,'col-head');const line=node('div');
const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]); const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]);
// Finished orders are a recent window, not the whole history: say so. // Finished orders are a recent window, not the whole history: say so.
const count=state==='fin'&&board.finished_total>board.finished_shown?orders.length+' de '+board.finished_total:String(orders.length); const count=state==='fin'&&board.finished_total>orders.length?orders.length+' de '+board.finished_total:String(orders.length);
line.append(swatch,node('h2',title),node('span',count,'n')); line.append(swatch,node('h2',title),node('span',count,'n'));
head.append(line,node('small',orders.length?metres(total)+' no total':'vazio')); head.append(line,node('small',orders.length?metres(total)+' no total':'vazio'));
column.append(head); column.append(head);
for(const order of orders)column.append(card(order)); for(const order of orders)column.append(card(order));
column.ondragover=e=>{e.preventDefault();column.classList.add('target');}; if(state==='fin'&&finishedMore&&!search&&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');};
column.ondragleave=()=>column.classList.remove('target'); column.ondragleave=()=>column.classList.remove('target');
column.ondrop=async e=>{e.preventDefault();column.classList.remove('target'); column.ondrop=async e=>{e.preventDefault();column.classList.remove('target');
const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain')); const order=board.orders.find(o=>o.id===e.dataTransfer.getData('text/plain'));
@@ -171,11 +184,26 @@ function card(order){
foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where')); foot.append(status,node('span',dest?'Entrega · '+dest.state:'Retirada','where'));
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot); 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) if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
c.append(node('div','Parado há mais de '+LATE_HOURS+' h nesta etapa','late')); c.append(node('div','Parado há mais de '+LATE_HOURS+' h','late'));
c.onclick=()=>openPanel(order.id); c.onclick=()=>openPanel(order.id);
c.ondragstart=e=>e.dataTransfer.setData('text/plain',order.id); c.ondragstart=e=>{
e.dataTransfer.setData('text/plain',order.id);
const forward=board.transitions[order.state].filter(s=>s!=='cor');
for(const col of document.querySelectorAll('.col[data-state]'))col.classList.toggle('allowed',forward.includes(col.dataset.state));
say(forward.length?'Mover para '+forward.map(s=>board.states[s]).join(' ou '):'Pedido finalizado',false,60000);
};
c.ondragend=()=>{for(const col of document.querySelectorAll('.col'))col.classList.remove('allowed','target');say('');};
return c; return c;
} }
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 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();
}
async function move(order,state,reason=''){ async function move(order,state,reason=''){
await api('/orders/'+order.id+'/move',{state,version:order.version,reason}); await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
await load(); await load();
@@ -240,17 +268,29 @@ function moves(order){
for(const next of board.transitions[order.state]){ for(const next of board.transitions[order.state]){
if(next==='cor'){ if(next==='cor'){
const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor'; const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor';
b.onclick=()=>{reason.hidden=!reason.hidden;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b); b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
}else{ }else{
const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b); const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b);
} }
} }
// Undo a mistaken move: one stage back, internal reason, no customer message.
const previous=board.back?.[order.state];
const backForm=node('form',undefined,'reason');backForm.hidden=true;
if(previous){
const b=node('button','Voltar para '+board.states[previous],'btn ghost');b.type='button';b.dataset.back=previous;
b.onclick=()=>{backForm.hidden=!backForm.hidden;reason.hidden=true;if(!backForm.hidden)backForm.querySelector('input').focus();};
row.append(b);
const why=node('input');why.placeholder='Motivo';why.setAttribute('aria-label','Motivo do retorno');why.required=true;why.maxLength=1000;
const go=node('button','Voltar para '+board.states[previous],'btn');
backForm.append(why,go);
backForm.onsubmit=async e=>{e.preventDefault();go.disabled=true;try{await move(order,previous,why.value.trim());}catch(error){say(error.message,true);go.disabled=false;}};
}
if(!row.children.length)row.append(node('span','Pedido concluído.','muted')); if(!row.children.length)row.append(node('span','Pedido concluído.','muted'));
const text=node('textarea');text.placeholder='O que o cliente precisa corrigir? (vai para o cliente)';text.maxLength=1000;text.required=true; const text=node('textarea');text.placeholder='Motivo da correção';text.setAttribute('aria-label','Motivo da correção');text.maxLength=1000;text.required=true;
const confirm=node('button','Enviar para correção','btn danger'); const confirm=node('button','Enviar para correção','btn danger');
reason.append(text,confirm); reason.append(text,confirm);
reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}}; reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}};
box.append(row,reason);return box; box.append(row,reason,backForm);return box;
} }
const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'}; const PRINT_TEXT={pending:'Na fila para gerar',rendering:'Gerando arquivo…',ready:'PDF gerado',manual:'Preparar à mão',failed:'Falhou ao gerar'};
function itemCard(order,item,index){ function itemCard(order,item,index){
@@ -278,7 +318,7 @@ function itemCard(order,item,index){
item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost'))); item.uploads.forEach((uid,i)=>buttons.append(button(item.uploads.length>1?'Original '+(i+1):'Baixar original',download(uid),'btn ghost')));
if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost')); if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost'));
if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed')) if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed'))
buttons.append(button('Gerar de novo',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost')); buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
body.append(buttons); body.append(buttons);
box.append(preview,body);return box; box.append(preview,body);return box;
} }
@@ -296,7 +336,6 @@ function finalsSection(order,revisions,section){
// after a customer correction, which replaced the artwork it was made from. // after a customer correction, which replaced the artwork it was made from.
const corrected=revisions.some(file=>file.kind==='correction'); const corrected=revisions.some(file=>file.kind==='correction');
const form=node('form'); const form=node('form');
form.append(node('p','Aprove um conjunto final completo. Um novo conjunto substitui o anterior.','muted'));
const inputs=order.snapshot.items.map((item,index)=>{ const inputs=order.snapshot.items.map((item,index)=>{
const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]); const slot=node('label',undefined,'slot');slot.append('Item '+(index+1)+' · '+PRODUCT[item.mode]);
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index; const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;
@@ -311,7 +350,7 @@ function finalsSection(order,revisions,section){
}); });
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000; const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;
const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm=''; const check=node('input');check.type='checkbox';check.required=true;check.dataset.confirm='';
const confirm=node('label',undefined,'check');confirm.append(check,'Conferi estes arquivos finais para impressão'); const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos conferidos');
const submit=node('button','Aprovar arquivos finais','btn primary'); const submit=node('button','Aprovar arquivos finais','btn primary');
form.append(note,confirm,submit); form.append(note,confirm,submit);
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{ form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
@@ -333,7 +372,7 @@ function historySection(order,rows,section){
const list=node('ol',undefined,'timeline'); const list=node('ol',undefined,'timeline');
for(const r of [...rows].reverse()){ for(const r of [...rows].reverse()){
const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]); const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]);
const text=node('div');text.append(node('b',board.states[r.from_state]+' → '+board.states[r.to_state])); const text=node('div');text.append(node('b',r.back?'Voltou para '+board.states[r.to_state]:board.states[r.from_state]+' → '+board.states[r.to_state]));
if(r.reason)text.append(node('span',' · '+r.reason,'muted')); if(r.reason)text.append(node('span',' · '+r.reason,'muted'));
text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li); text.append(node('small',when(r.created_at)+' · '+r.operator));li.append(text);list.append(li);
} }
@@ -345,38 +384,39 @@ function historySection(order,rows,section){
// ---- Quotes -------------------------------------------------------------- // ---- Quotes --------------------------------------------------------------
function allQuotes(){return [...board.quotes,...extraQuotes.pending,...extraQuotes.approved];}
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);} function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
async function loadMoreQuotes(kind){ let quoteRows=[], quoteTotal=0, quotePage=1, quoteSize=20, currentQuote=null, quoteLoading=0;
const shown=allQuotes().filter(q=>kind==='pending'?!q.approved:!!q.approved); async function loadQuotes(){
const last=shown.at(-1);const params=new URLSearchParams({kind,limit:'50'}); const token=++quoteLoading;
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);} const page=await api('/quotes?'+new URLSearchParams({kind:quoteKind,limit:String(quoteSize),offset:String((quotePage-1)*quoteSize)}));
const page=await api('/quotes?'+params);const known=new Set(allQuotes().map(q=>q.id)); if(token!==quoteLoading)return;
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));moreQuotes[kind]=page.has_more;renderQuotes(); quoteRows=page.quotes;quoteTotal=page.total;
if(quotePage>1&&!quoteRows.length&&quoteTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();}
renderQuotes();
} }
function renderQuotes(){ function renderQuotes(){
const quotes=allQuotes();
const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]]; const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]];
$('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{ $('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)); const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key));
b.onclick=()=>{quoteKind=key;renderQuotes();};return b;})); b.onclick=()=>{quoteKind=key;quotePage=1;currentQuote=null;loadQuotes().catch(e=>say(e.message,true));};return b;}));
const visible=quotes.filter(q=>quoteKind==='pending'?q.status==='pending_review':q.status!=='pending_review'); if(!currentQuote||(!quoteRows.some(q=>q.id===currentQuote.id)&&currentQuote.status==='pending_review'))currentQuote=quoteRows[0]||null;
if(!pickedQuote||!quotes.some(q=>q.id===pickedQuote))pickedQuote=visible[0]?.id||null; else currentQuote=quoteRows.find(q=>q.id===currentQuote.id)||currentQuote;
const list=visible.map(q=>{ 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===pickedQuote)); 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');left.append(node('b',q.draft.customer.mail),
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q)))); 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')); 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')); else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
const right=node('div',undefined,'right'); 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))); right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
b.append(left,right);b.onclick=()=>{pickedQuote=q.id;renderQuotes();};return b; b.append(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(!list.length)list.push(node('p',quoteKind==='pending'?'Nenhuma cotação para revisar.':'Nenhuma cotação aprovada aguardando pagamento.','faint'));
if(moreQuotes[quoteKind])list.push(button('Carregar mais antigas',()=>loadMoreQuotes(quoteKind),'btn ghost')); if(quoteTotal>quoteSize||quotePage>1)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); $('quote-list').replaceChildren(...list);
const quote=quotes.find(q=>q.id===pickedQuote); $('reviews').replaceChildren(currentQuote?review(currentQuote):node('p','Selecione uma cotação.','note'));
$('reviews').replaceChildren(quote?review(quote):node('p','Selecione uma cotação.','note'));
} }
function review(quote){ function review(quote){
const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id; const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id;
@@ -387,12 +427,12 @@ function review(quote){
const content=node('div',undefined,'content'); const content=node('div',undefined,'content');
const foot=node('div',undefined,'foot'); const foot=node('div',undefined,'foot');
if(quote.status==='pending_review'&&quote.draft.items.some(item=>item.production?.version!==2)){ if(quote.status==='pending_review'&&quote.draft.items.some(item=>item.production?.version!==2)){
content.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.','muted')); content.append(node('p','Montagem antiga. Solicite uma nova cotação ao cliente.','muted'));
card.append(head,content);return card; card.append(head,content);return card;
} }
if(quote.status!=='pending_review'){ if(quote.status!=='pending_review'){
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.': content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
'Aprovada: '+money(quote.approved.total_cents)+'. Aguardando o pagamento do cliente.','muted')); 'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
for(const item of quote.draft.items)content.append(quoteItem(item,null)); for(const item of quote.draft.items)content.append(quoteItem(item,null));
card.append(head,content);return card; card.append(head,content);return card;
} }
@@ -400,12 +440,14 @@ function review(quote){
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields)); const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});}); return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});});
const check=node('input');check.type='checkbox';check.required=true; const check=node('input');check.type='checkbox';check.required=true;
const confirm=node('label',undefined,'check');confirm.append(check,'Conferi arquivos, metragem (com repetições e montagem) e nota'); const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos');
const submit=node('button','Aprovar cotação','btn primary'); const submit=node('button','Aprovar cotação','btn primary');
foot.append(confirm,submit); foot.append(confirm,submit);
form.append(content,foot); form.append(content,foot);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true; form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;
try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();} try{const approved=await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});
// Keep showing the quote just approved while its list refreshes.
currentQuote={...quote,approved,status:'approved'};say('Cotação aprovada.');await load();}
catch(error){say(error.message,true);submit.disabled=false;}}; catch(error){say(error.message,true);submit.disabled=false;}};
card.append(head,form);return card; card.append(head,form);return card;
} }
@@ -453,57 +495,155 @@ function integration(name,state,tone,text,actions=[]){
if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);} if(actions.length){const b=node('div',undefined,'buttons');b.append(...actions);box.append(b);}
return box; return box;
} }
function renderIntegrations(){ // The page bar under every list: page size, range, first/previous/page/next/last.
const issues=board.payment_issues||[]; const PAGE_SIZES=[10,20,50,100];
const payments=[node('h2','Pagamentos que precisam de atenção')]; function pager({page,size,total,noun,onPage,onSize,compact=false}){
if(!issues.length)payments.push(node('p','Nenhum pagamento pendente de resolução.','faint')); const pages=Math.max(1,Math.ceil(total/size));
for(const issue of issues){ const bar=node('div',undefined,'pager'+(compact?' compact':''));
const box=node('div',undefined,'issue');box.append(icon(ICON_WARN));box.firstChild.style.color='var(--warn)'; const sizeLabel=node('label',undefined,'pager-size');
const select=node('select');select.setAttribute('aria-label','Itens por página');
for(const n of PAGE_SIZES){const o=node('option',String(n));o.value=String(n);o.selected=n===size;select.append(o);}
select.onchange=()=>onSize(Number(select.value));
sizeLabel.append('Mostrar',select,'por página');
const nav=node('div',undefined,'pager-nav');
const from=total?(page-1)*size+1:0, to=Math.min(total,page*size);
const go=(label,aria,target,disabled)=>{const b=node('button',label,'pg');b.type='button';b.setAttribute('aria-label',aria);b.disabled=disabled;b.onclick=()=>onPage(target);return b;};
const pageLabel=node('label',undefined,'pager-page');
const input=node('input');input.type='number';input.min='1';input.max=String(pages);input.value=String(page);input.setAttribute('aria-label','Página');
input.onchange=()=>onPage(Math.min(pages,Math.max(1,Math.round(Number(input.value))||1)));
pageLabel.append(compact?'':'Página',input,'de '+pages);
nav.append(go('«','Primeira página',1,page<=1),go('‹','Página anterior',page-1,page<=1),pageLabel,
go('›','Próxima página',page+1,page>=pages),go('»','Última página',pages,page>=pages));
bar.append(sizeLabel,node('span',(compact?'':'Mostrando ')+from+' a '+to+' de '+total+(compact?'':' '+noun),'pager-range'),nav);
return bar;
}
function chips(container,options,current,pick){
container.replaceChildren(...options.map(([key,label])=>{
const b=node('button',label,'chip');b.type='button';b.setAttribute('aria-pressed',String(current===key));
b.onclick=()=>pick(key);return b;}));
}
function moreButton(container,has,fn){
if(!has)return;
const b=button('Carregar mais',fn,'btn ghost');b.style.alignSelf='flex-start';b.style.marginTop='8px';container.append(b);
}
// Payments needing a person: paged, open or resolved (history).
let issueState='open', issueRows=[], issueTotal=0, issuePage=1, issueSize=20, issueLoading=0;
async function loadIssues(){
const token=++issueLoading;
const page=await api('/payment-events?'+new URLSearchParams({state:issueState,limit:String(issueSize),offset:String((issuePage-1)*issueSize)}));
if(token!==issueLoading)return;
issueRows=page.issues;issueTotal=page.total;
if(issuePage>1&&!issueRows.length&&issueTotal){issuePage=Math.ceil(issueTotal/issueSize);return loadIssues();}
renderIssues();
}
function renderIssues(){
const filters=node('div',undefined,'chips');filters.style.marginBottom='12px';
chips(filters,[['open','Abertos'],['resolved','Resolvidos'],['all','Todos']],issueState,key=>{issueState=key;issuePage=1;loadIssues().catch(e=>say(e.message,true));});
const list=node('div');
if(!issueRows.length)list.append(node('p',issueState==='resolved'?'Nenhum pagamento resolvido.':'Nenhum pagamento pendente.','faint'));
for(const issue of issueRows){
const box=node('div',undefined,'issue');
if(issue.resolved_at)box.classList.add('done');
box.append(issue.resolved_at?icon(ICON_OK):icon(ICON_WARN));box.firstChild.style.color=issue.resolved_at?'var(--ok)':'var(--warn)';
const text=node('div',undefined,'text'); const text=node('div',undefined,'text');
text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)), text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)),
node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at))); node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at)));
const form=node('form');const note=node('input');note.placeholder='O que foi feito? (ex.: estornado em 25/09)';note.required=true;note.minLength=3;note.maxLength=1000; if(issue.resolved_at){
const save=node('button','Registrar resolução','btn warn');form.append(note,save); text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution));
form.onsubmit=async e=>{e.preventDefault();save.disabled=true; box.append(text);
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});await load();}catch(error){say(error.message,true);save.disabled=false;}}; }else{
box.append(text,form);payments.push(box); const form=node('form');const note=node('input');note.placeholder='Resolução';note.setAttribute('aria-label','Resolução');note.required=true;note.minLength=3;note.maxLength=1000;
const save=node('button','Registrar resolução','btn warn');form.append(note,save);
form.onsubmit=async e=>{e.preventDefault();save.disabled=true;
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});say('Resolução registrada.');await load();}
catch(error){say(error.message,true);save.disabled=false;}};
box.append(text,form);
}
list.append(box);
} }
$('payments').replaceChildren(...payments); const bar=pager({page:issuePage,size:issueSize,total:issueTotal,noun:'pagamentos',
onPage:n=>{issuePage=n;loadIssues().catch(e=>say(e.message,true));},
onSize:n=>{issueSize=n;issuePage=1;loadIssues().catch(e=>say(e.message,true));}});
$('payments').replaceChildren(filters,list,bar);
}
// The integration send log: filtered and paged on the server.
let eventFilter={provider:'',status:'',event:'',order:''}, eventRows=[], eventTotal=0, eventPage=1, eventSize=20, eventLoading=0, eventTimer=null;
async function loadEvents(){
const token=++eventLoading;
const params=new URLSearchParams({limit:String(eventSize),offset:String((eventPage-1)*eventSize)});
for(const [key,value] of Object.entries(eventFilter))if(value)params.set(key,value);
const page=await api('/events?'+params);
if(token!==eventLoading)return;
eventRows=page.events;eventTotal=page.total;
if(eventPage>1&&!eventRows.length&&eventTotal){eventPage=Math.ceil(eventTotal/eventSize);return loadEvents();}
renderEvents();
}
function eventFilters(){
const bar=$('event-filters');if(bar.dataset.ready)return;bar.dataset.ready='1';
const select=(label,key,options)=>{
const field=node('label',undefined,'field');field.append(label);
const s=node('select');for(const [value,text] of options){const o=node('option',text);o.value=value;s.append(o);}
s.onchange=()=>{eventFilter[key]=s.value;eventPage=1;loadEvents().catch(e=>say(e.message,true));};
field.append(s);return field;
};
const order=node('label',undefined,'field');order.append('Pedido');
const input=node('input');input.type='number';input.min='1';input.placeholder='Nº';input.setAttribute('inputmode','numeric');
input.oninput=()=>{clearTimeout(eventTimer);eventTimer=setTimeout(()=>{eventFilter.order=input.value.trim();eventPage=1;loadEvents().catch(e=>say(e.message,true));},350);};
order.append(input);
bar.append(
select('Destino','provider',[['','Todos'],['tiny','Tiny'],['whatsapp','WhatsApp']]),
select('Situação','status',[['','Todas'],['delivered','Enviados'],['queued','Na fila'],['failing','Com erro']]),
select('Evento','event',[['','Todos'],...Object.entries(EVENTS)]),
order);
}
function eventResult(e){
const receipt=e.receipt||{};
if(e.delivered_at){
if(receipt.status==='created')return ['Criado no Tiny nº '+receipt.tiny_number,'good'];
if(receipt.status==='already-created')return ['Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)','good'];
if(receipt.status==='not-applicable')return ['Não se aplica ao Tiny',''];
return ['Registrado, sem envio',''];
}
if(e.last_error)return ['Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')','retry'];
return ['Na fila',''];
}
function renderEvents(){
const rows=eventRows.map(e=>{
const tr=node('tr');const [result,cls]=eventResult(e);
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),
node('td',{tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider),
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
return tr;
});
if(!rows.length){const tr=node('tr');const td=node('td','Nenhum envio encontrado.','faint');td.colSpan=5;tr.append(td);rows.push(tr);}
$('events').replaceChildren(...rows);
$('events-more').replaceChildren(pager({page:eventPage,size:eventSize,total:eventTotal,noun:'envios',
onPage:n=>{eventPage=n;loadEvents().catch(e=>say(e.message,true));},
onSize:n=>{eventSize=n;eventPage=1;loadEvents().catch(e=>say(e.message,true));}}));
}
function renderIntegrations(){
const t=board.tiny||{};const cards=[]; const t=board.tiny||{};const cards=[];
if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Defina TINY_CLIENT_ID, TINY_CLIENT_SECRET e TINY_REDIRECT_URI no servidor.')); if(!t.configured)cards.push(integration('Tiny','Não configurado','off','Credenciais ausentes no servidor.'));
else{ else{
const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn'); const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn');
const actions=[connect]; const actions=[connect];
if(t.connected){const result=node('p'); if(t.connected){
actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{}); actions.unshift(button('Testar conexão',async()=>{const r=await api('/tiny/test',{});
say(r.ok?'Tiny respondeu: leitura de pedidos e contatos ok.':'Tiny: pedidos '+r.results.pedidos+' · contatos '+r.results.contatos,!r.ok);}));} say(r.ok?'Conexão com o Tiny ok.':'Tiny: pedidos '+r.results.pedidos+' · contatos '+r.results.contatos,!r.ok);}));}
cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn', cards.push(integration('Tiny',t.connected?'Conectado':'Não conectado',t.connected?'ok':'warn',
t.connected?'Por '+t.connected_by+' desde '+when(t.connected_at)+'. Envio de pedidos '+(t.orders_enabled?'ligado.':'desligado.'): t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
'Clique em Conectar e aprove o acesso com um login do Tiny.',actions)); 'Conecte com uma conta do Tiny.',actions));
} }
const mp=board.providers?.payment; const mp=board.providers?.payment;
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off', cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
mp==='mercadopago'?'PIX e cartão ativos no Site.':'Aguardando a conta e as chaves do Mercado Pago. O Site ainda não recebe pagamentos.')); mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.'));
cards.push(integration('Frete','Não configurado','off','Aguardando a plataforma de frete. Por enquanto só retirada em Franca.')); cards.push(integration('Frete','Não configurado','off','Somente retirada.'));
cards.push(integration('WhatsApp','Não configurado','off','As mensagens ficam registradas abaixo, sem envio ao cliente.')); cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
$('integrations').replaceChildren(...cards); $('integrations').replaceChildren(...cards);
eventFilters();
$('events').replaceChildren(...board.events.map(e=>{
const tr=node('tr');const receipt=e.receipt||{};let result, cls='';
if(e.delivered_at){
if(receipt.status==='created'){result='Criado no Tiny nº '+receipt.tiny_number;cls='good';}
else if(receipt.status==='already-created'){result='Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)';cls='good';}
else if(receipt.status==='not-applicable'){result='Não enviado (o Tiny recebe só a venda)';}
else result='Registrado, sem envio';
}else if(e.last_error){result='Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')';cls='retry';}
else result='Na fila';
const provider={tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider;
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),node('td',provider),
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
return tr;
}));
if(!board.events.length){const tr=node('tr');const td=node('td','Nenhum envio ainda.','faint');td.colSpan=5;tr.append(td);$('events').append(tr);}
} }
// ---- Wiring -------------------------------------------------------------- // ---- Wiring --------------------------------------------------------------