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 ..printjobs import queue as queue_print_files
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)
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
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']
orders = 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']]
orders = with_print_files(c, active + list(reversed(finished)))
# 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
# person records what was done about it.
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
ORDER BY received_at LIMIT 100''').fetchall()
return {'states': STATES, 'transitions': TRANSITIONS,
'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), 'operator': user,
# for nothing the factory will make. The board carries the count; the
# Pagamentos tab pages through them until someone records a resolution.
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']
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'pending_total': pending_total, 'approved_total': approved_total,
'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()}
'pending_total': pending_total, 'approved_total': approved_total}
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')
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),
user=Depends(operator)):
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."""
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'
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:
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''', 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]],
'has_more':len(rows)>limit}
'has_more':len(rows)>limit, 'total': total}
@router.post('/api/operator/quotes/{uid}/approve')
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:
raise HTTPException(404, 'Order not found')
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']:
return row
if body.state not in TRANSITIONS[row['state']]:
raise HTTPException(409, 'Move is not allowed from this state')
back = BACK.get(row['state']) == body.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():
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'):
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']))):
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':
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)',
(uid,row['state'],body.state,user,body.reason))
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,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()
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'):
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,
'customer_path': f'/portal.html?order={uid}'})
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'}
TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'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):

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

View File

@@ -80,10 +80,15 @@ operator interfaces.
preselected instead (see "Print files"). Continue with the panel's main
button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir
correção** asks for the reason the customer will see; from **Correção** the
order returns to **Arte recebida** or **Arte tratada**.
8. The panel's **Histórico** lists every move. The **Pagamentos e
integrações** tab shows the Tiny connection, payments that need a person,
and the send log; with the fake adapters every send reads "Registrado
order returns to **Arte recebida** or **Arte tratada**. A move made by
mistake is undone with **Voltar para …**: one stage back, with an internal
reason recorded in the history. The customer is not notified, approved
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
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
(money without an order, refunds after an order) until resolved. Confirm
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.
`[!]` 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 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.
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');

View File

@@ -109,7 +109,7 @@ def run():
# The customer is told once, not once per delivery.
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']
and e['payload'].get('event') == 'payment_approved']
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,
'status': 'approved', 'amount_cents': total - 1})
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)
assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
operator=True, expected=422)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'note': 'Local test: refunded the underpayment'}, operator=True)
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
{'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)
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__':

View File

@@ -185,10 +185,21 @@ def run():
version+=1;assert moved['version']==version
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
# 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')
deadline=time.monotonic()+30
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
time.sleep(1)
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:empty{display:none}
.who{font-size:13px;color:var(--muted);white-space:nowrap;max-width:220px;overflow:hidden;text-overflow:ellipsis}
#status{margin:0;padding:8px 24px 0;min-height:28px;font-size:13px;color:var(--muted)}
#status.error,#login-status.error{color:var(--bad)}
#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:empty,#login-status:empty{display:none}
#status.error{border-color:var(--bad-line);color:var(--bad)}
#login-status.error{color:var(--bad)}
/* Board */
main{padding:0 24px 24px}
.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 0 12px}
.bar{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}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.bar .chips{margin-left:auto}
.kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:12px;overflow-x:auto;padding-bottom:8px;align-items:start}
.col{display:flex;flex-direction:column;gap:10px;background:var(--col);border:1px solid #22262B;border-radius:12px;padding:12px;min-height:320px}
.col.allowed{border-color:rgba(255,139,43,.45);background:#17140F}
.col.target{border-color:var(--accent)}
.col-head{display:flex;flex-direction:column;gap:6px;padding:2px 2px 8px;border-bottom:1px solid #22262B}
.col-head div{display:flex;align-items:center;gap:8px}
@@ -130,6 +133,7 @@ main{padding:0 24px 24px}
.timeline small{display:block;color:var(--faint);font-size:12px}
/* 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 .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%}
@@ -151,13 +155,30 @@ main{padding:0 24px 24px}
.pane .note{padding:24px;color:var(--muted)}
/* 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}
.issue{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:16px 18px;border-radius:12px;border:1px solid #4A3A1E;background:#1C1A15;margin-bottom:8px}
.issue .text{flex:1;min-width:260px;display:flex;flex-direction:column;gap:4px}
.issue .text b{font-size:14px}
.issue .text span{font-size:13px;color:var(--muted);overflow-wrap:anywhere}
.issue form{display:flex;gap:8px;align-items:center}
.issue.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}
.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)}

View File

@@ -3,7 +3,7 @@
<title>Kanban DTF</title>
<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="/kanban.css?v=redesign-2">
<link rel="stylesheet" href="/kanban.css?v=redesign-7">
</head><body>
<form id="login" class="login" hidden>
<div class="brand"><i aria-hidden="true">D</i>Kanban DTF</div>
@@ -19,7 +19,8 @@
<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="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>
<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>
@@ -46,12 +47,17 @@
<div class="pane" id="reviews"></div>
</div>
</section>
<section id="tab-integrations" class="page" hidden>
<h1>Pagamentos e integrações</h1>
<section id="tab-payments" class="page" hidden>
<h1>Pagamentos</h1>
<section id="payments"></section>
<section><h2>Integrações</h2><div class="grid4" id="integrations"></div></section>
<section><h2>Registro de envios <span class="faint" style="font-weight:500;font-size:13px">últimos 100</span></h2>
</section>
<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>
<div id="events-more"></div>
</section>
</section>
</main>
@@ -61,4 +67,4 @@
<div class="scrim" id="scrim"></div>
<aside class="panel" id="panel" aria-label="Pedido"></aside>
</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 tab='board', filter='all', search='', openOrder=null, openToken=0;
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 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;}};
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);}
if(text)sayTimer=setTimeout(()=>say(''),ms??(error?8000:4000));
}
function icon(path){
const ns='http://www.w3.org/2000/svg';const svg=document.createElementNS(ns,'svg');
@@ -77,11 +82,11 @@ function manifest(production){
async function load(){
try{
board=await api('/board');
extraQuotes={pending:[],approved:[]};
moreQuotes={pending:board.pending_total>board.quotes.filter(q=>!q.approved).length,
approved:board.approved_total>board.quotes.filter(q=>!!q.approved).length};
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;
$('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();
}catch(e){
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){
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(){
$('who').textContent=board.operator||'';
$('count-board').textContent=String(board.orders.filter(o=>o.state!=='fin').length);
$('badge-quotes').textContent=board.pending_total?String(board.pending_total):'';
$('badge-payments').textContent=board.payment_issues?.length?String(board.payment_issues.length):'';
tinyHeader();renderBoard();renderQuotes();renderIntegrations();
$('badge-payments').textContent=board.payment_issues_total?String(board.payment_issues_total):'';
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();}
}
@@ -144,12 +154,15 @@ function renderBoard(){
const head=node('div',undefined,'col-head');const line=node('div');
const swatch=node('span',undefined,'swatch');swatch.style.setProperty('--c',STAGE_COLORS[state]);
// Finished orders are a recent window, not the whole history: say so.
const count=state==='fin'&&board.finished_total>board.finished_shown?orders.length+' de '+board.finished_total:String(orders.length);
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'));
head.append(line,node('small',orders.length?metres(total)+' no total':'vazio'));
column.append(head);
for(const order of orders)column.append(card(order));
column.ondragover=e=>{e.preventDefault();column.classList.add('target');};
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.ondrop=async e=>{e.preventDefault();column.classList.remove('target');
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'));
c.append(top,node('div',order.snapshot.customer.mail,'cust'),tags,foot);
if(['rec','tra','fil','imp'].includes(order.state)&&Date.now()-new Date(order.updated_at)>LATE_HOURS*3600000)
c.append(node('div','Parado há mais de '+LATE_HOURS+' h nesta etapa','late'));
c.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);
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;
}
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=''){
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});
await load();
@@ -240,17 +268,29 @@ function moves(order){
for(const next of board.transitions[order.state]){
if(next==='cor'){
const b=node('button','Pedir correção','btn danger');b.type='button';b.dataset.move='cor';
b.onclick=()=>{reason.hidden=!reason.hidden;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
b.onclick=()=>{reason.hidden=!reason.hidden;backForm.hidden=true;if(!reason.hidden)reason.querySelector('textarea').focus();};row.append(b);
}else{
const b=button('Mover para '+board.states[next],()=>move(order,next),'btn primary');b.dataset.move=next;row.append(b);
}
}
// 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'));
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');
reason.append(text,confirm);
reason.onsubmit=async e=>{e.preventDefault();confirm.disabled=true;try{await move(order,'cor',text.value.trim());}catch(error){say(error.message,true);confirm.disabled=false;}};
box.append(row,reason);return box;
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'};
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')));
if(spec?.placements)buttons.append(button('Manifesto',()=>manifest(spec),'btn ghost'));
if(['rec','tra'].includes(order.state)&&(!row||row.status==='manual'||row.status==='failed'))
buttons.append(button('Gerar de novo',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
buttons.append(button('Gerar novamente',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();},'btn ghost'));
body.append(buttons);
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.
const corrected=revisions.some(file=>file.kind==='correction');
const form=node('form');
form.append(node('p','Aprove um conjunto final completo. Um novo conjunto substitui o anterior.','muted'));
const inputs=order.snapshot.items.map((item,index)=>{
const 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;
@@ -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 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');
form.append(note,confirm,submit);
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');
for(const r of [...rows].reverse()){
const li=node('li');li.style.setProperty('--c',STAGE_COLORS[r.to_state]);
const text=node('div');text.append(node('b',board.states[r.from_state]+' → '+board.states[r.to_state]));
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'));
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 --------------------------------------------------------------
function allQuotes(){return [...board.quotes,...extraQuotes.pending,...extraQuotes.approved];}
function quoteMetres(q){return (q.approved||q.draft).items.reduce((s,i)=>s+Number(i.billed_metres||i.metres),0);}
async function loadMoreQuotes(kind){
const shown=allQuotes().filter(q=>kind==='pending'?!q.approved:!!q.approved);
const last=shown.at(-1);const params=new URLSearchParams({kind,limit:'50'});
if(last){params.set('before_created_at',last.created_at);params.set('before_id',last.id);}
const page=await api('/quotes?'+params);const known=new Set(allQuotes().map(q=>q.id));
extraQuotes[kind].push(...page.quotes.filter(q=>!known.has(q.id)));moreQuotes[kind]=page.has_more;renderQuotes();
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)}));
if(token!==quoteLoading)return;
quoteRows=page.quotes;quoteTotal=page.total;
if(quotePage>1&&!quoteRows.length&&quoteTotal){quotePage=Math.ceil(quoteTotal/quoteSize);return loadQuotes();}
renderQuotes();
}
function renderQuotes(){
const quotes=allQuotes();
const kinds=[['pending','A revisar',board.pending_total],['approved','Aprovadas, aguardando pagamento',board.approved_total]];
$('quote-filters').replaceChildren(...kinds.map(([key,label,count])=>{
const b=node('button',label+' · '+count,'chip');b.type='button';b.setAttribute('aria-pressed',String(quoteKind===key));
b.onclick=()=>{quoteKind=key;renderQuotes();};return b;}));
const visible=quotes.filter(q=>quoteKind==='pending'?q.status==='pending_review':q.status!=='pending_review');
if(!pickedQuote||!quotes.some(q=>q.id===pickedQuote))pickedQuote=visible[0]?.id||null;
const list=visible.map(q=>{
const b=node('button',undefined,'qitem');b.type='button';b.dataset.quotePick=q.id;b.setAttribute('aria-current',String(q.id===pickedQuote));
b.onclick=()=>{quoteKind=key;quotePage=1;currentQuote=null;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),
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
const right=node('div',undefined,'right');
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
b.append(left,right);b.onclick=()=>{pickedQuote=q.id;renderQuotes();};return b;
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(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);
const quote=quotes.find(q=>q.id===pickedQuote);
$('reviews').replaceChildren(quote?review(quote):node('p','Selecione uma cotação.','note'));
$('reviews').replaceChildren(currentQuote?review(currentQuote):node('p','Selecione uma cotação.','note'));
}
function review(quote){
const card=node('article',undefined,'review');card.style.display='contents';card.dataset.quote=quote.id;
@@ -387,12 +427,12 @@ function review(quote){
const content=node('div',undefined,'content');
const foot=node('div',undefined,'foot');
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;
}
if(quote.status!=='pending_review'){
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
'Aprovada: '+money(quote.approved.total_cents)+'. Aguardando o pagamento do cliente.','muted'));
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
for(const item of quote.draft.items)content.append(quoteItem(item,null));
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));
return ()=>({...item,metres:fields.metres.value,grade:Number(fields.grade.value)});});
const check=node('input');check.type='checkbox';check.required=true;
const confirm=node('label',undefined,'check');confirm.append(check,'Conferi arquivos, metragem (com repetições e montagem) e nota');
const confirm=node('label',undefined,'check');confirm.append(check,'Arquivos, metragem e nota conferidos');
const submit=node('button','Aprovar cotação','btn primary');
foot.append(confirm,submit);
form.append(content,foot);
form.onsubmit=async e=>{e.preventDefault();submit.disabled=true;
try{await api('/quotes/'+quote.id+'/approve',{items:edits.map(fn=>fn())});await load();}
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;}};
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);}
return box;
}
function renderIntegrations(){
const issues=board.payment_issues||[];
const payments=[node('h2','Pagamentos que precisam de atenção')];
if(!issues.length)payments.push(node('p','Nenhum pagamento pendente de resolução.','faint'));
for(const issue of issues){
const box=node('div',undefined,'issue');box.append(icon(ICON_WARN));box.firstChild.style.color='var(--warn)';
// The page bar under every list: page size, range, first/previous/page/next/last.
const PAGE_SIZES=[10,20,50,100];
function pager({page,size,total,noun,onPage,onSize,compact=false}){
const pages=Math.max(1,Math.ceil(total/size));
const bar=node('div',undefined,'pager'+(compact?' compact':''));
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');
text.append(node('b',(issue.amount_cents==null?'Pagamento sem valor informado':'Pagamento de '+money(issue.amount_cents))+': '+outcomeText(issue.outcome)),
node('span',issue.provider+' · evento '+issue.event_id+' · cotação '+(issue.reference||'—')+' · '+when(issue.received_at)));
const form=node('form');const note=node('input');note.placeholder='O que foi feito? (ex.: estornado em 25/09)';note.required=true;note.minLength=3;note.maxLength=1000;
const save=node('button','Registrar resolução','btn warn');form.append(note,save);
form.onsubmit=async e=>{e.preventDefault();save.disabled=true;
try{await api('/payment-events/'+issue.id+'/resolve',{note:note.value});await load();}catch(error){say(error.message,true);save.disabled=false;}};
box.append(text,form);payments.push(box);
if(issue.resolved_at){
text.append(node('span','Resolvido por '+issue.resolved_by+' em '+when(issue.resolved_at)+': '+issue.resolution));
box.append(text);
}else{
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=[];
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{
const connect=button(t.connected?'Reconectar':'Conectar Tiny',async()=>{const r=await api('/tiny/connect',{});location.href=r.url;},t.connected?'btn ghost':'btn');
const actions=[connect];
if(t.connected){const result=node('p');
if(t.connected){
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',
t.connected?'Por '+t.connected_by+' desde '+when(t.connected_at)+'. Envio de pedidos '+(t.orders_enabled?'ligado.':'desligado.'):
'Clique em Conectar e aprove o acesso com um login do Tiny.',actions));
t.connected?t.connected_by+' · '+when(t.connected_at)+' · envio de pedidos '+(t.orders_enabled?'ativo':'desativado'):
'Conecte com uma conta do Tiny.',actions));
}
const mp=board.providers?.payment;
cards.push(integration('Mercado Pago',mp==='mercadopago'?'Ativo':'Não configurado',mp==='mercadopago'?'ok':'off',
mp==='mercadopago'?'PIX e cartão ativos no Site.':'Aguardando a conta e as chaves do Mercado Pago. O Site ainda não recebe pagamentos.'));
cards.push(integration('Frete','Não configurado','off','Aguardando a plataforma de frete. Por enquanto só retirada em Franca.'));
cards.push(integration('WhatsApp','Não configurado','off','As mensagens ficam registradas abaixo, sem envio ao cliente.'));
mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.'));
cards.push(integration('Frete','Não configurado','off','Somente retirada.'));
cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
$('integrations').replaceChildren(...cards);
$('events').replaceChildren(...board.events.map(e=>{
const tr=node('tr');const receipt=e.receipt||{};let result, cls='';
if(e.delivered_at){
if(receipt.status==='created'){result='Criado no Tiny nº '+receipt.tiny_number;cls='good';}
else if(receipt.status==='already-created'){result='Já existia no Tiny nº '+receipt.tiny_number+' (reenvio)';cls='good';}
else if(receipt.status==='not-applicable'){result='Não enviado (o Tiny recebe só a venda)';}
else result='Registrado, sem envio';
}else if(e.last_error){result='Nova tentativa · '+e.last_error+' (tentativa '+e.attempts+')';cls='retry';}
else result='Na fila';
const provider={tiny:'Tiny',whatsapp:'WhatsApp'}[e.provider]||e.provider;
tr.append(node('td',when(e.delivered_at||e.available_at),'muted'),node('td','#'+e.payload.number,'mono'),node('td',provider),
node('td',EVENTS[e.payload.event]||e.payload.event),node('td',result,cls));
return tr;
}));
if(!board.events.length){const tr=node('tr');const td=node('td','Nenhum envio ainda.','faint');td.colSpan=5;tr.append(td);$('events').append(tr);}
eventFilters();
}
// ---- Wiring --------------------------------------------------------------