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
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:
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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'`)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)}'
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&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>
|
||||||
|
|||||||
294
web/kanban.js
294
web/kanban.js
@@ -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&"eTotal){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)&¤tQuote.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'&"e.draft.items.some(item=>item.production?.version!==2)){
|
if(quote.status==='pending_review'&"e.draft.items.some(item=>item.production?.version!==2)){
|
||||||
content.append(node('p','Cotação com montagem antiga. Peça ao cliente para enviar uma nova cotação.','muted'));
|
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 --------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user