diff --git a/app/adapters.py b/app/adapters.py
index 4cac37a..19172e7 100644
--- a/app/adapters.py
+++ b/app/adapters.py
@@ -146,6 +146,8 @@ class FreightAdapter(Protocol):
def quote(self, service: str, postal_code: str) -> dict: ...
class FakeFreight:
+ name = 'fake'
+
def quote(self, service: str, postal_code: str) -> dict:
if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
diff --git a/app/api/customer.py b/app/api/customer.py
index 2a4c4d5..ff5704b 100644
--- a/app/api/customer.py
+++ b/app/api/customer.py
@@ -57,7 +57,7 @@ def login(body: Login, request: Request, response: Response):
if not account or not matches:
login_failed(email)
audit('customer_login_failed', ip=client_ip(request))
- raise HTTPException(401, 'Invalid email or password')
+ raise HTTPException(401, 'E-mail ou senha inválidos.')
previous = current(request)
with db.connect() as c:
if not stored.startswith('scrypt-v2$'):
diff --git a/app/api/health.py b/app/api/health.py
index 30c0320..76b7a60 100644
--- a/app/api/health.py
+++ b/app/api/health.py
@@ -7,7 +7,8 @@ from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes
from ..core.models import Freight
-from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
+from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
+ require_delivery_available, storage)
router = APIRouter()
@@ -40,6 +41,7 @@ def session(request: Request, response: Response):
@router.post('/api/freight')
def quote_freight(body: Freight):
+ require_delivery_available(body.service)
try:
return freight.quote(body.service, body.postal_code)
except ValueError as exc:
diff --git a/app/api/operator.py b/app/api/operator.py
index 433d6d4..c6a46cf 100644
--- a/app/api/operator.py
+++ b/app/api/operator.py
@@ -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, STATES, TRANSITIONS,
+from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
enqueue, freight, quote_view, storage, upload_row)
from ..scanning import require_clean
@@ -30,14 +30,14 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
with db.connect() as c:
account = c.execute('SELECT * FROM dtf_local.operators WHERE email=%s', (email,)).fetchone()
if not c.execute('SELECT 1 FROM dtf_local.operators WHERE active LIMIT 1').fetchone():
- raise HTTPException(503, 'No Kanban operator account is configured')
+ raise HTTPException(503, 'Nenhuma conta de operador configurada.')
# Comparable password work whether or not the account exists or is active.
stored = account['password_hash'] if account else DUMMY_PASSWORD_HASH
matches = password_matches(body.password, stored)
if not account or not account['active'] or not matches:
login_failed('operator:'+email)
audit('operator_login_failed', ip=client_ip(request), operator=email)
- raise HTTPException(401, 'Invalid operator login')
+ raise HTTPException(401, 'E-mail ou senha inválidos.')
token = secrets.token_urlsafe(32)
with db.connect() as c:
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
@@ -97,7 +97,8 @@ def board(user=Depends(operator)):
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(),
+ 'orders': orders, 'payment_issues': refused, '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,
@@ -224,6 +225,18 @@ def tiny_connect(user=Depends(operator)):
audit('tiny_connect_started', operator=user)
return {'url': tiny.TinyAuth().authorize_url(user)}
+@router.post('/api/operator/tiny/test')
+def tiny_test(user=Depends(operator)):
+ """Read one order and one contact from Tiny. Creates nothing."""
+ if not tiny.configured():
+ raise HTTPException(503, 'Tiny application is not configured')
+ try:
+ results = tiny.check()
+ except tiny.TinyNotConnected as exc:
+ raise HTTPException(409, str(exc))
+ audit('tiny_tested', operator=user, ok=all(v == 'ok' for v in results.values()))
+ return {'ok': all(v == 'ok' for v in results.values()), 'results': results}
+
@router.get('/api/operator/tiny/callback')
def tiny_callback(code: str = Query(max_length=4096), state: str = Query(max_length=128)):
"""Tiny's redirect back. Cross-site, so the operator cookie is absent: the
diff --git a/app/api/quotes.py b/app/api/quotes.py
index dcf38f4..b79770c 100644
--- a/app/api/quotes.py
+++ b/app/api/quotes.py
@@ -10,7 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db
from ..core.auth import owner
from ..core.models import QuoteRequest
-from ..runtime import freight, quote_view, upload_row
+from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..scanning import require_clean
router = APIRouter()
@@ -20,6 +20,7 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
for item in body.items:
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
+ require_delivery_available(body.freight.service)
draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try:
diff --git a/app/api/uploads.py b/app/api/uploads.py
index 5d95428..cf2ef26 100644
--- a/app/api/uploads.py
+++ b/app/api/uploads.py
@@ -37,7 +37,7 @@ def begin_upload(body: UploadStart, session_id=Depends(owner)):
usage['owned']+body.size > int(os.environ.get('OWNER_UPLOAD_QUOTA_BYTES','10737418240')) or
usage['pending'] >= int(os.environ.get('MAX_PENDING_UPLOADS','10'))):
audit('upload_quota_rejected')
- raise HTTPException(429, 'Local storage quota or pending upload limit reached')
+ raise HTTPException(429, 'Limite de armazenamento ou de envios pendentes atingido. Tente de novo mais tarde.')
multipart = storage.begin(key)
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,expires_at)
VALUES(%s,%s,%s,%s,%s,%s,now()+interval '1 hour')''',
diff --git a/app/core/auth.py b/app/core/auth.py
index f049ca4..1fc9cf6 100644
--- a/app/core/auth.py
+++ b/app/core/auth.py
@@ -45,7 +45,7 @@ def session_row(request):
try:
sid = UUID(request.cookies.get('dtf_session', ''))
except ValueError:
- raise HTTPException(401, 'Start a local session first')
+ raise HTTPException(401, 'Sessão não iniciada. Recarregue a página.')
with connect() as c:
row = c.execute('SELECT * FROM dtf_local.sessions WHERE id=%s AND expires_at>now()', (sid,)).fetchone()
if not row:
@@ -92,7 +92,7 @@ def rate_limit(scope, identity, limit, seconds=900):
RETURNING attempts""", (key,seconds,seconds)).fetchone()
if row['attempts'] > limit:
audit('rate_limit', scope=scope)
- raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After':str(seconds)})
+ raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After':str(seconds)})
ACCOUNT_FAILURES = 10
@@ -108,7 +108,7 @@ def throttle(email, request):
WHERE key=%s AND started_at >= now()-interval '900 seconds'""", (key,)).fetchone()
if row and row['attempts'] >= ACCOUNT_FAILURES:
audit('rate_limit', scope='auth-account')
- raise HTTPException(429, 'Too many requests. Try again later.', headers={'Retry-After': '900'})
+ raise HTTPException(429, 'Muitas tentativas. Tente de novo mais tarde.', headers={'Retry-After': '900'})
def login_failed(email):
"""Count a failed sign-in (or registration attempt) against the account."""
@@ -117,10 +117,10 @@ def login_failed(email):
def operator(request: Request):
token = request.cookies.get('dtf_operator', '')
if not token or len(token)>128:
- raise HTTPException(401, 'Sign in to the local Kanban')
+ raise HTTPException(401, 'Entre no Kanban.')
digest = hashlib.sha256(token.encode()).hexdigest()
with connect() as c:
row = c.execute('SELECT username FROM dtf_local.operator_sessions WHERE token_hash=%s AND expires_at>now()', (digest,)).fetchone()
if not row:
- raise HTTPException(401, 'Operator session expired')
+ raise HTTPException(401, 'Sua sessão expirou. Entre de novo.')
return row['username']
diff --git a/app/printfile.py b/app/printfile.py
index 5f3484c..398fd90 100644
--- a/app/printfile.py
+++ b/app/printfile.py
@@ -46,7 +46,8 @@ Image.MAX_IMAGE_PIXELS = None
class Unsupported(Exception):
- """This item cannot be generated automatically; the reason is for the operator."""
+ """This item cannot be generated automatically. The reason is shown to the
+ operator on the Kanban, so it is written in Portuguese."""
class Name(str):
@@ -153,14 +154,14 @@ class SourceImage:
self.image = Image.open(path)
self.format = self.image.format
except Image.DecompressionBombError as exc:
- raise Unsupported(f'"{name}" is too large to generate automatically') from exc
+ raise Unsupported(f'"{name}" é grande demais para gerar automaticamente') from exc
except Exception as exc:
- raise Unsupported(f'"{name}" is not an image this generator can read') from exc
+ raise Unsupported(f'"{name}" não é uma imagem que o gerador consiga ler') from exc
if self.format not in SUPPORTED_FORMATS:
- raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
- 'only JPEG, PNG, WebP and TIFF are generated automatically')
+ raise Unsupported(f'"{name}" está em {self.format or "formato desconhecido"}; '
+ 'só JPEG, PNG, WebP, TIFF e PDF são gerados automaticamente')
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
- raise Unsupported(f'"{name}" is animated or has several frames')
+ raise Unsupported(f'"{name}" é animado ou tem vários quadros')
# Browsers draw a photo upright according to its EXIF orientation, and
# the Site measured it that way, so the print must too.
try:
@@ -181,8 +182,8 @@ class SourceImage:
return self._embed_jpeg(pdf)
width, height = self.image.size
if width * height > MAX_DECODED_PIXELS:
- raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
- 'generator decodes; prepare this item by hand')
+ raise Unsupported(f'"{self.name}" tem {width} × {height} px, mais do que o '
+ 'gerador processa; prepare este item à mão')
return self._embed_pixels(pdf)
def _colorspace(self, pdf, mode):
@@ -235,7 +236,7 @@ class SourceImage:
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
color_mode = 'RGB'
else:
- raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
+ raise Unsupported(f'"{self.name}" usa o modo de cor {mode}, que não é gerado automaticamente')
if has_alpha:
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
width, height = image.size
@@ -304,29 +305,29 @@ class PdfPage:
try:
self.pdf = pikepdf.open(path)
except pikepdf.PasswordError as exc:
- raise Unsupported(f'"{name}" is password-protected') from exc
+ raise Unsupported(f'"{name}" está protegido por senha') from exc
except Exception as exc:
- raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
+ raise Unsupported(f'"{name}" não é um PDF que o gerador consiga ler') from exc
try:
pages = len(self.pdf.pages)
if pages != 1:
- raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
+ raise Unsupported(f'"{name}" tem {pages} páginas; só PDFs de uma página são gerados automaticamente')
self.page = self.pdf.pages[0]
self.rotation = int(self.page.rotation) % 360
if self.rotation not in (0, 90, 180, 270):
- raise Unsupported(f'"{name}" has an unsupported page rotation')
+ raise Unsupported(f'"{name}" tem uma rotação de página não suportada')
box = [float(v) for v in self.page.cropbox]
except Unsupported:
self.pdf.close()
raise
except Exception as exc:
self.pdf.close()
- raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
+ raise Unsupported(f'"{name}" tem uma página que o gerador não consegue ler') from exc
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
if self.w <= 0 or self.h <= 0:
self.pdf.close()
- raise Unsupported(f'"{name}" has an empty page')
+ raise Unsupported(f'"{name}" tem uma página vazia')
# As displayed, which is what the proportions are checked against.
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
@@ -373,7 +374,7 @@ def check_layout(item, sizes, vector=()):
height = Decimal(str(production['height_cm']))
billed = Decimal(str(item['billed_metres'])) * 100
if height > billed + HEIGHT_TOLERANCE_CM:
- raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
+ raise Unsupported(f'a montagem tem {height} cm, mas só {billed} cm foram cobrados')
lowest = None
for placement in production['placements']:
width_px, height_px = sizes[placement['source_index']]
@@ -384,8 +385,8 @@ def check_layout(item, sizes, vector=()):
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
if drift > ASPECT_TOLERANCE:
source = production['sources'][placement['source_index']]
- raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
- f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
+ raise Unsupported(f'o arquivo {placement["source_index"] + 1} tem proporções diferentes '
+ f'das cotadas ({source["width_cm"]} × {source["length_cm"]} cm)')
if placement['source_index'] in vector:
continue
dpi = width_px / (width_cm / 2.54)
@@ -405,7 +406,7 @@ def render(item, files, out, title):
"""
production = item['production']
if production.get('version') != 2:
- raise Unsupported('item uses an obsolete production layout')
+ raise Unsupported('o item usa uma montagem antiga')
sources = []
try:
for index in range(len(production['sources'])):
diff --git a/app/printjobs.py b/app/printjobs.py
index 9dfd6e5..03667d8 100644
--- a/app/printjobs.py
+++ b/app/printjobs.py
@@ -101,9 +101,9 @@ def produce(storage, job, item, uploads):
for upload_id in item['uploads']:
row = uploads.get(upload_id)
if not row or row['scan_state'] != 'clean':
- raise Unsupported('an original file is missing or not cleared by the malware scan')
+ raise Unsupported('um arquivo original está ausente ou não foi liberado pelo antivírus')
if row['purged_at'] or row['expired']:
- raise Unsupported('an original file has passed its retention period')
+ raise Unsupported('um arquivo original passou do prazo de guarda')
number = job['number']
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
diff --git a/app/runtime.py b/app/runtime.py
index f6db7b3..591c86b 100644
--- a/app/runtime.py
+++ b/app/runtime.py
@@ -48,6 +48,13 @@ TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'],
'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []}
+def require_delivery_available(service):
+ """Outside the local stack, a simulated freight price must never reach a
+ customer: until a real freight provider exists, only pickup is offered."""
+ if service != 'pickup' and ENVIRONMENT != 'local' and getattr(freight, 'name', '') == 'fake':
+ raise HTTPException(503, 'A entrega ainda não está disponível. Escolha a retirada em Franca.')
+
+
def upload_row(c, upload_id, session_id, lock=False):
row = c.execute('SELECT * FROM dtf_local.uploads WHERE id=%s AND owner=%s' +
(' FOR UPDATE' if lock else ''), (upload_id, session_id)).fetchone()
diff --git a/app/tiny.py b/app/tiny.py
index 1547c86..b8f6dea 100644
--- a/app/tiny.py
+++ b/app/tiny.py
@@ -223,6 +223,25 @@ def order_payload(payload, contact_id, today=None):
return pedido
+def check(auth=None, transport=None):
+ """Read-only proof that the connection and permissions work: one order and
+ one contact listed, nothing created. Raises TinyNotConnected when there is
+ no usable connection; otherwise reports each read separately."""
+ orders = TinyOrders(auth=auth or TinyAuth(), transport=transport)
+ results = {}
+ for name, path in (('pedidos', '/pedidos'), ('contatos', '/contatos')):
+ try:
+ orders.request('GET', path, params={'limit': 1})
+ results[name] = 'ok'
+ except TinyNotConnected:
+ raise
+ except TinyError as exc:
+ results[name] = str(exc)[:200]
+ except httpx.HTTPError:
+ results[name] = 'sem resposta do Tiny'
+ return results
+
+
class TinyOrders:
def __init__(self, auth=None, transport=None, today=None):
missing = [name for name in required_settings() if not os.environ.get(name)]
diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md
index 07bfa45..8f4ddaa 100644
--- a/docs/LOCAL_SETUP.md
+++ b/docs/LOCAL_SETUP.md
@@ -62,25 +62,30 @@ operator interfaces.
4. Click the Site checkout button. Files upload directly to MinIO, remain
quarantined until ClamAV returns `clean`, and then become eligible for a quote.
The local banner displays a quote ID awaiting commercial review.
-5. Open Kanban and log in. In **Cotações**, download the original if needed,
- confirm/correct total metres and grade, tick the manual confirmation, and
- click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
+5. Open Kanban and log in. Open the **Cotações** tab, pick the quote, download
+ the original if needed, confirm/correct metres and grade, tick the
+ confirmation at the bottom and click **Aprovar cotação**. For the fixture
+ keep 1.01 m and grade 0.
6. Return to the Site and click its checkout action again. Inspect the
authoritative server total, then click **Criar pedido de teste**. No real
payment occurs.
-7. Refresh Kanban. The paid order starts in **Arte recebida**. Move it using
- the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
- select the manually prepared final files for every item, enter a review note,
- tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
- fixture again only for this local test. The text fixture is not an image, so
- its print file shows **preparar à mão**; with a PNG or JPEG artwork the
- generated PDF is preselected instead (see "Print files").
- Continue through **Fila de impressão →
- Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
- return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
-8. Inspect **Histórico** and **Eventos locais de integração**. Worker receipts
- should say recorded locally. Download links expire after five minutes;
- request another link from Kanban when needed.
+7. Click **Atualizar** on the Kanban. The paid order appears in **Arte
+ recebida** on the **Produção** tab. Click its card to open the order panel,
+ then **Mover para Arte tratada** (dragging the card to a column also works).
+ In the panel's **Arquivos finais**, select the manually prepared final file
+ for every item, enter a review note, tick the confirmation and click
+ **Aprovar arquivos finais**. Use the harmless fixture again only for this
+ local test. The text fixture is not an image, so its print file shows
+ **Preparar à mão**; with a PNG, JPEG or PDF artwork the generated file is
+ 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
+ (simulado)". Download links expire after five minutes; click again for a new
+ one. The previous Kanban is kept in git tag `ui-v1`.
The manual quote step is necessary to enforce the backend trust boundary while
automatic pre-flight is deferred. Browser measurements and grade are proposals.
diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs
index 85f8991..004ab27 100644
--- a/tests/browser_test.mjs
+++ b/tests/browser_test.mjs
@@ -92,6 +92,11 @@ try{
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
await kanban.eval('document.getElementById("login").requestSubmit()');
+ // Quotes live in their own tab; the review pane shows the one picked.
+ await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
+ await kanban.click('[data-tab="quotes"]');
+ await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
+ await kanban.click(`[data-quote-pick="${qid}"]`);
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
@@ -114,19 +119,23 @@ try{
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
assert.deepEqual(await kanban.eval(`(()=>{const spec=board.orders.find(o=>o.id===${JSON.stringify(oid)}).snapshot.items[0].production;const source=spec.sources[0];return {kind:source.kind,copies:source.copies,length:Number(source.length_cm),height:Number(spec.height_cm),placed:spec.placements.length}})()`),
{kind:'sheet',copies:1,length:101,height:101,placed:1});
- // Click real transition buttons, including rerender after each move.
+ // Open the order's panel from its card, then click the real transition
+ // buttons there, including the rerender after each move.
+ await kanban.click('[data-tab="board"]');
+ await kanban.click(`[data-card="${oid}"]`);
+ await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move]')`),'order panel');
for(const [title,state] of [['Arte tratada','tra'],['Fila de impressão','fil'],['Imprimindo','imp'],['Finalizado','fin']]){
if(state==='fil'){
- await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent==='Arquivos de produção').click();})()`);
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-final-item]')`),'final upload controls');
const doc=await kanban.call('DOM.getDocument');
const input=await kanban.call('DOM.querySelector',{nodeId:doc.root.nodeId,selector:`[data-order="${oid}"] [data-final-item]`});
await kanban.call('DOM.setFileInputFiles',{nodeId:input.nodeId,files:[resolve('tests/fixtures/local-test.cdr')]});
await kanban.fill(`[data-order="${oid}"] input[placeholder="Nota da revisão"]`,'Browser test final file');
- await kanban.eval(`(()=>{const form=document.querySelector('[data-order="${oid}"] form');form.querySelector('[type=checkbox]').click();form.requestSubmit();})()`);
+ await kanban.eval(`(()=>{const check=document.querySelector('[data-order="${oid}"] [data-confirm]');check.click();check.closest('form').requestSubmit();})()`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').version===2`),'final file approval');
}
- await kanban.eval(`(()=>{const card=document.querySelector('[data-order="${oid}"]');[...card.querySelectorAll('button')].find(x=>x.textContent===${JSON.stringify('→ '+title)}).click();})()`);
+ await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="${state}"]')`),'move to '+title);
+ await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`);
await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state);
}
// Reload proves the board is persisted on the backend.
diff --git a/tests/print_file_test.py b/tests/print_file_test.py
index 9b6a82a..213d76c 100644
--- a/tests/print_file_test.py
+++ b/tests/print_file_test.py
@@ -126,7 +126,7 @@ def run():
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
order = paid_order(client, loose_item(manual, copies=1))
order, row = wait_print(client, order['id'], 'manual')
- assert 'not an image' in row['detail']['reason'], row
+ assert 'não é uma imagem' in row['detail']['reason'], row
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
assert rows[0]['status'] == 'pending'
wait_print(client, order['id'], 'manual')
diff --git a/tests/test_printfile.py b/tests/test_printfile.py
index aed5b19..1e7b2e9 100644
--- a/tests/test_printfile.py
+++ b/tests/test_printfile.py
@@ -115,19 +115,19 @@ class PrintFileTests(unittest.TestCase):
def test_refuses_what_it_cannot_reproduce(self):
png = self.save(quadrants(), 'a.png')
- with self.assertRaisesRegex(Unsupported, 'proportions'):
+ with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
- with self.assertRaisesRegex(Unsupported, 'billed'):
+ with self.assertRaisesRegex(Unsupported, 'cobrados'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
with open(fake_pdf, 'wb') as handle:
handle.write(b'%PDF-1.4\n%%EOF\n')
- with self.assertRaisesRegex(Unsupported, 'not a PDF'):
+ with self.assertRaisesRegex(Unsupported, 'não é um PDF'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
cdr = os.path.join(self.dir.name, 'art.cdr')
with open(cdr, 'wb') as handle:
handle.write(b'not artwork')
- with self.assertRaisesRegex(Unsupported, 'not an image'):
+ with self.assertRaisesRegex(Unsupported, 'não é uma imagem'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [cdr])
def test_long_layouts_scale_user_space_instead_of_splitting(self):
@@ -247,12 +247,12 @@ class PdfSourceTests(unittest.TestCase):
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
def test_refuses_pdfs_it_cannot_place(self):
- with self.assertRaisesRegex(Unsupported, '2 pages'):
+ with self.assertRaisesRegex(Unsupported, '2 páginas'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [self.pdf('two.pdf', pages=2)])
- with self.assertRaisesRegex(Unsupported, 'password'):
+ with self.assertRaisesRegex(Unsupported, 'senha'):
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10),
[self.pdf('locked.pdf', password='secret')])
- with self.assertRaisesRegex(Unsupported, 'proportions'):
+ with self.assertRaisesRegex(Unsupported, 'proporções'):
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [self.pdf('square.pdf')])
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
diff --git a/tests/test_tiny.py b/tests/test_tiny.py
index 9e5b55d..f261f73 100644
--- a/tests/test_tiny.py
+++ b/tests/test_tiny.py
@@ -43,8 +43,8 @@ class TinyTests(unittest.TestCase):
self.calls.append((request.method, path))
assert request.headers['authorization'] == 'Bearer access-1'
if request.method == 'GET' and path == '/contatos':
- cnpj = request.url.params['cpfCnpj']
- return httpx.Response(200, json={'itens': [c for c in self.contacts if c['cpfCnpj'] == cnpj]})
+ cnpj = request.url.params.get('cpfCnpj')
+ return httpx.Response(200, json={'itens': [c for c in self.contacts if cnpj in (None, c['cpfCnpj'])]})
if request.method == 'POST' and path == '/contatos':
contact = {**json.loads(request.content), 'id': 500 + len(self.contacts)}
self.contacts.append(contact)
@@ -61,6 +61,7 @@ class TinyTests(unittest.TestCase):
return httpx.Response(200, json={'id': order['id'], 'numeroPedido': order['numeroPedido']})
return httpx.Response(404)
+ self.handler = handler
self.tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(handler),
today=date(2026, 9, 24))
@@ -106,6 +107,14 @@ class TinyTests(unittest.TestCase):
self.tiny.deliver('k3', PAID)
self.assertNotIn(('POST', '/pedidos'), self.calls)
+ def test_connection_check_only_reads(self):
+ from app.tiny import check
+ results = check(auth=FakeAuth(), transport=httpx.MockTransport(self.handler))
+ self.assertEqual(results, {'pedidos': 'ok', 'contatos': 'ok'})
+ self.assertTrue(all(method == 'GET' for method, _ in self.calls))
+ denied = check(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(403, text='forbidden')))
+ self.assertTrue(denied['pedidos'].startswith('GET /pedidos: 403'))
+
def test_rate_limit_is_a_retryable_failure(self):
tiny = TinyOrders(auth=FakeAuth(), transport=httpx.MockTransport(lambda r: httpx.Response(429)))
with self.assertRaisesRegex(TinyError, 'rate limit'):
diff --git a/web/checkout.js b/web/checkout.js
index 7bb5bbc..622d5b6 100644
--- a/web/checkout.js
+++ b/web/checkout.js
@@ -127,7 +127,9 @@
return;
}
if (quote.status==='pending_review') {
- message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
+ message((await ready).environment==='local'
+ ? 'Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.'
+ : 'Arquivos enviados. Nossa equipe está conferindo a cotação '+draftId.slice(0,8)+'; o valor final aparece aqui em seguida.');
} else if (quote.status==='approved') {
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
if ((await ready).payment_provider === 'mercadopago') {
@@ -163,7 +165,8 @@
} catch(error) { message(error.message); event.target.disabled=false; }
});
} else if (quote.status==='paid') {
- message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
+ const STAGES={rec:'arte recebida',tra:'arte tratada',fil:'na fila de impressão',imp:'imprimindo',cor:'aguardando correção',fin:'finalizado'};
+ message('Pedido #'+quote.order.number+' · '+(STAGES[quote.order.state]||quote.order.state)+'.');
button('Novo pedido',()=>{clearDraft();location.reload();});
} else {
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
diff --git a/web/kanban.css b/web/kanban.css
new file mode 100644
index 0000000..265e344
--- /dev/null
+++ b/web/kanban.css
@@ -0,0 +1,182 @@
+/* Kanban DTF. The previous look is kept in git tag ui-v1. */
+:root{
+ --bg:#0F1113;--bar:#13161A;--col:#13161A;--card:#1A1D21;--card2:#23282D;--panel:#15181B;
+ --line:#262A2F;--line2:#2A2F35;--line3:#3A4047;
+ --tx:#ECE9E4;--tx2:#D5D2CC;--muted:#A3A9B0;--faint:#8B929A;
+ --accent:#FF8B2B;--accent-tx:#FFB575;--ok:#6FD19C;--warn:#F2B33D;--bad:#F4968B;--bad-line:#5A3530;
+ --rec:#2BB6D9;--tra:#E0508F;--fil:#E9C23A;--imp:#D9D6D0;--cor:#F06A5B;--fin:#3FB97A;
+ --mono:Manrope,system-ui,sans-serif;
+}
+*{box-sizing:border-box}
+html,body{margin:0;background:var(--bg);color:var(--tx)}
+body{font:14px/1.45 Manrope,system-ui,sans-serif;font-variant-numeric:tabular-nums}
+button,input,textarea{font:inherit;color:inherit}
+button{cursor:pointer}
+button:disabled{opacity:.5;cursor:default}
+a{color:var(--accent-tx)}
+[hidden]{display:none!important}
+.mono{font-family:var(--mono)}
+.muted{color:var(--muted)}
+.faint{color:var(--faint)}
+.btn{height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:var(--card2);color:var(--tx);font-size:13px;font-weight:600;white-space:nowrap}
+.btn:hover{border-color:var(--line3)}
+.btn.ghost{background:transparent;color:var(--muted)}
+.btn.primary{height:44px;border:0;background:var(--accent);color:#0F1113;font-weight:800;font-size:14px;padding:0 18px}
+.btn.primary:hover{filter:brightness(1.08)}
+.btn.danger{height:44px;background:transparent;border-color:var(--bad-line);color:var(--bad);font-weight:700;font-size:14px}
+.btn.warn{border:0;background:var(--warn);color:#0F1113;font-weight:800}
+.chip{height:34px;padding:0 12px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);font-size:13px;font-weight:600}
+.chip[aria-pressed=true]{border-color:var(--accent);background:rgba(255,139,43,.12);color:var(--accent-tx)}
+.badge{font-size:12px;font-weight:700;border-radius:999px;padding:1px 8px;background:var(--accent);color:#0F1113}
+.badge.warn{background:var(--warn);color:#0F1113}
+.badge:empty{display:none}
+.eyebrow{font-size:12px;color:var(--faint);font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0 0 8px}
+input[type=text],input[type=email],input[type=password],input[type=number],input:not([type]),textarea{height:40px;padding:0 12px;border-radius:8px;border:1px solid var(--line3);background:var(--bg);color:var(--tx)}
+textarea{height:auto;padding:10px 12px;min-height:72px;width:100%;resize:vertical}
+input[type=checkbox]{width:18px;height:18px;accent-color:var(--accent)}
+input[type=file]{font-size:13px;color:var(--muted);max-width:100%}
+.check{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--tx2)}
+.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
+
+/* Sign-in */
+.login{max-width:380px;margin:12vh auto 0;padding:32px;border:1px solid var(--line2);border-radius:14px;background:var(--panel);display:flex;flex-direction:column;gap:14px}
+.login label{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600;color:var(--muted)}
+.login .hint{font-size:12px;color:var(--faint);margin:0}
+
+/* Top bar */
+.top{height:64px;padding:0 24px;display:flex;align-items:center;gap:22px;border-bottom:1px solid var(--line);background:var(--bar);position:sticky;top:0;z-index:5}
+.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:16px;letter-spacing:-.01em;white-space:nowrap}
+.brand i{width:28px;height:28px;border-radius:7px;background:var(--accent);color:#0F1113;font-style:normal;font-weight:800;font-size:15px;display:flex;align-items:center;justify-content:center}
+.tabs{display:flex;gap:4px;align-self:stretch}
+.tabs button{display:flex;align-items:center;gap:8px;padding:0 14px;border:0;border-bottom:2px solid transparent;background:none;color:var(--muted);font-weight:600;font-size:14px}
+.tabs button[aria-selected=true]{color:var(--tx);font-weight:700;border-bottom-color:var(--accent)}
+.tabs .count{font-family:var(--mono);font-size:12px;color:var(--muted)}
+.search{display:flex;align-items:center;gap:8px;width:300px;height:38px;padding:0 12px;border:1px solid var(--line2);border-radius:9px;background:var(--card);color:var(--faint);margin-left:auto}
+.search input{flex:1;min-width:0;border:0;background:transparent;height:auto;padding:0;outline:none}
+.tinydot{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);white-space:nowrap}
+.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)}
+
+/* Board */
+main{padding:0 24px 24px}
+.bar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 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.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}
+.col-head h2{margin:0;font-size:14px;font-weight:700}
+.col-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--c)}
+.col-head .n{margin-left:auto;font-family:var(--mono);font-size:12px;color:var(--muted)}
+.col-head small{font-size:12px;color:var(--faint)}
+.col .empty{font-size:12px;color:var(--faint);padding:8px 2px}
+.card{text-align:left;display:flex;flex-direction:column;gap:8px;padding:12px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
+.card:hover,.card.open{border-color:var(--line3);background:#1E2226}
+.card .row{display:flex;align-items:baseline;gap:8px;width:100%}
+.card .num{font-size:14px;font-weight:800}
+.card .age{margin-left:auto;font-size:12px;color:var(--faint)}
+.card .cust{font-size:13px;font-weight:600;color:var(--tx);width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.tags{display:flex;flex-wrap:wrap;gap:6px}
+.tag{font-size:12px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)}
+.card .foot{display:flex;align-items:center;gap:6px;font-size:12px}
+.card .foot .where{margin-left:auto;color:var(--muted)}
+.late{font-size:12px;color:#F06A5B;font-weight:600}
+
+/* Order panel */
+.drawer{position:fixed;inset:0;z-index:10;display:flex;justify-content:flex-end}
+.scrim{position:absolute;inset:0;background:rgba(8,9,11,.6)}
+.panel{position:relative;width:min(600px,100vw);height:100%;background:var(--panel);border-left:1px solid var(--line2);display:flex;flex-direction:column}
+.panel-head{padding:20px 24px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:14px}
+.panel-head .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.panel-head .num{font-size:22px;font-weight:800}
+.stage{font-size:12px;font-weight:700;padding:4px 10px;border-radius:999px;background:color-mix(in srgb,var(--c) 18%,transparent);color:var(--c)}
+.close{margin-left:auto;width:40px;height:40px;border-radius:8px;border:1px solid var(--line2);background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center}
+.steps{margin:0;padding:0;list-style:none;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px}
+.steps li{display:flex;flex-direction:column;gap:6px;font-size:11px;color:var(--faint)}
+.steps li::before{content:"";height:4px;border-radius:4px;background:var(--line2)}
+.steps li.done{color:var(--muted)}.steps li.done::before{background:var(--c)}
+.steps li.now{color:var(--tx);font-weight:700}.steps li.now::before{background:var(--c)}
+.moves{display:flex;gap:10px;flex-wrap:wrap}
+.moves .primary{flex:1}
+.reason{display:flex;flex-direction:column;gap:8px}
+.panel-body{flex:1;overflow:auto;padding:20px 24px 32px;display:flex;flex-direction:column;gap:24px}
+.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
+.facts b{display:block;font-size:14px}
+.facts span{font-size:13px;color:var(--muted);overflow-wrap:anywhere}
+.item{display:flex;gap:14px;padding:14px;border:1px solid var(--line2);border-radius:12px;background:var(--card)}
+.item.attention{border-color:#4A3A1E;background:#1C1A15}
+.item .preview{width:96px;flex-shrink:0;border-radius:8px;background:var(--card2);display:flex;align-items:flex-start;justify-content:center;padding:6px}
+.item .preview svg{width:100%;height:auto;display:block}
+.item .body{flex:1;min-width:0;display:flex;flex-direction:column;gap:8px}
+.item .title{display:flex;align-items:baseline;gap:8px}
+.item .title b{font-size:14px}
+.item .title .mono{margin-left:auto;font-size:13px}
+.item .line{font-size:12px;color:var(--muted)}
+.item .file{display:flex;align-items:center;gap:8px;font-size:13px}
+.buttons{display:flex;gap:8px;flex-wrap:wrap}
+.finals{display:flex;flex-direction:column;gap:12px}
+.finals .rev{font-size:13px;display:flex;flex-direction:column;gap:4px;padding:10px 12px;border:1px solid var(--line2);border-radius:10px}
+.finals form{display:flex;flex-direction:column;gap:12px;padding:14px;border:1px solid var(--line2);border-radius:12px}
+.finals .slot{display:flex;flex-direction:column;gap:6px;font-size:13px;font-weight:600}
+.timeline{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:10px}
+.timeline li{display:flex;gap:12px;font-size:13px}
+.timeline li::before{content:"";width:8px;height:8px;margin-top:6px;border-radius:999px;background:var(--c,var(--muted));flex-shrink:0}
+.timeline small{display:block;color:var(--faint);font-size:12px}
+
+/* Quotes */
+.split{display:flex;gap:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;min-height:calc(100vh - 140px)}
+.split .list{width:460px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;display:flex;flex-direction:column;gap:12px;background:#111316}
+.qitem{text-align:left;display:flex;justify-content:space-between;gap:12px;padding:14px;border-radius:10px;border:1px solid var(--line2);background:var(--card);width:100%}
+.qitem[aria-current=true]{border-color:var(--accent);background:#1F1A15}
+.qitem b{display:block;font-size:14px;overflow-wrap:anywhere}
+.qitem span{font-size:12px;color:var(--muted)}
+.qitem .right{text-align:right;flex-shrink:0}
+.qitem .flag{display:block;color:var(--warn);font-weight:600;margin-top:4px}
+.pane{flex:1;min-width:0;display:flex;flex-direction:column}
+.pane .head{padding:18px 24px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+.pane .head b{font-size:18px;overflow-wrap:anywhere}
+.pane .content{flex:1;padding:20px 24px;display:flex;flex-direction:column;gap:16px}
+.pane .fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
+.pane .fields label{display:flex;flex-direction:column;gap:6px;font-size:12px;font-weight:600;color:var(--muted)}
+.pane .fields input[readonly]{color:var(--faint);background:var(--panel);border-color:var(--line2)}
+.pane .foot{padding:16px 24px;border-top:1px solid var(--line);background:var(--bar);display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+.pane .foot .total{margin-left:auto;font-family:var(--mono);color:var(--muted)}
+.pane .foot .total b{color:var(--tx)}
+.pane .note{padding:24px;color:var(--muted)}
+
+/* Payments and integrations */
+.page{display:flex;flex-direction:column;gap:22px;padding-top:8px}
+.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 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)}
+.integration .head{display:flex;align-items:center;gap:8px}
+.integration .head b{font-size:15px}
+.integration .head .dot{width:9px;height:9px;border-radius:999px;background:var(--dot,#5B6168)}
+.integration .head em{margin-left:auto;font-style:normal;font-size:12px;font-weight:700;color:var(--dotx,var(--muted))}
+.integration p{margin:0;font-size:13px;color:var(--muted)}
+.integration .buttons{margin-top:auto}
+table.log{width:100%;border-collapse:collapse;font-size:13px}
+table.log th{text-align:left;color:var(--faint);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--line)}
+table.log td{padding:10px 12px;border-bottom:1px solid #1E2226;vertical-align:top}
+.good{color:var(--ok);font-weight:600}.retry{color:var(--warn);font-weight:600}
+
+@media (max-width:900px){
+ .top{flex-wrap:wrap;height:auto;padding:10px 16px;gap:10px}
+ .search{order:5;width:100%;margin-left:0}
+ main{padding:0 16px 16px}
+ .split{flex-direction:column}
+ .split .list{width:auto;border-right:0;border-bottom:1px solid var(--line)}
+ .facts,.pane .fields{grid-template-columns:1fr}
+}
diff --git a/web/kanban.html b/web/kanban.html
index 49466b0..5b52fa1 100644
--- a/web/kanban.html
+++ b/web/kanban.html
@@ -1,21 +1,64 @@
-DTF · Kanban local
-
-
Kanban DTF
-
Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.