feat: configure Kanban login with optional email
This commit is contained in:
@@ -14,7 +14,7 @@ S3_APP_USER=dtf_app
|
|||||||
S3_APP_PASSWORD=local-app-storage-only
|
S3_APP_PASSWORD=local-app-storage-only
|
||||||
S3_BUCKET=dtf-local-artwork
|
S3_BUCKET=dtf-local-artwork
|
||||||
S3_PUBLIC_ENDPOINT=http://localhost:9000
|
S3_PUBLIC_ENDPOINT=http://localhost:9000
|
||||||
OPERATOR_USER=operator
|
OPERATOR_EMAIL=operator@example.test
|
||||||
OPERATOR_PASSWORD=local-operator-only
|
OPERATOR_PASSWORD=local-operator-only
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
PAYMENT_ADAPTER=fake
|
PAYMENT_ADAPTER=fake
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ resumes when local access returns.
|
|||||||
Actions workflow tests/scans, publishes `latest` plus commit-SHA application
|
Actions workflow tests/scans, publishes `latest` plus commit-SHA application
|
||||||
images, and calls the Portainer webhook. Activation remains blocked pending
|
images, and calls the Portainer webhook. Activation remains blocked pending
|
||||||
the production work listed below.
|
the production work listed below.
|
||||||
|
- **Kanban access:** configure `OPERATOR_EMAIL` and `OPERATOR_PASSWORD` as
|
||||||
|
stack environment variables. If the email is absent, only Kanban login is
|
||||||
|
blocked; the rest of the stack remains available.
|
||||||
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing
|
- **Recommended VPS baseline:** 4 vCPU, 16 GB RAM, and 200 GB NVMe. Existing
|
||||||
VPS capacity may be used if it safely meets or exceeds this baseline.
|
VPS capacity may be used if it safely meets or exceeds this baseline.
|
||||||
- **Backups:** PostgreSQL backups go to R2. Application secrets are never
|
- **Backups:** PostgreSQL backups go to R2. Application secrets are never
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ operator interfaces.
|
|||||||
5. Open Kanban and log in. In **Cotações**, download the original if needed,
|
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
|
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.
|
click **Aprovar cotação**. For the fixture keep 1.01 m and grade 0.
|
||||||
6. Return to the Site, click **Atualizar pedido local**, inspect the authoritative
|
6. Return to the Site and click its checkout action again. Inspect the
|
||||||
server total, then click **Criar pedido pago local**. No real payment occurs.
|
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
|
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**,
|
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,
|
select the manually prepared final files for every item, enter a review note,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ POSTGRES_DB=dtf
|
|||||||
POSTGRES_USER=dtf_admin
|
POSTGRES_USER=dtf_admin
|
||||||
APP_DB_USER=dtf_app
|
APP_DB_USER=dtf_app
|
||||||
POSTGRES_VOLUME=TBD
|
POSTGRES_VOLUME=TBD
|
||||||
OPERATOR_USER=TBD
|
OPERATOR_EMAIL=TBD
|
||||||
|
|
||||||
PAYMENT_ADAPTER=TBD
|
PAYMENT_ADAPTER=TBD
|
||||||
FREIGHT_ADAPTER=TBD
|
FREIGHT_ADAPTER=TBD
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ x-app-environment: &app-environment
|
|||||||
AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID}
|
AWS_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set R2_ACCESS_KEY_ID}
|
||||||
AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY}
|
AWS_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set R2_SECRET_ACCESS_KEY}
|
||||||
AWS_DEFAULT_REGION: auto
|
AWS_DEFAULT_REGION: auto
|
||||||
OPERATOR_USER: operator
|
# Optional at deploy time so a missing Kanban credential cannot make the
|
||||||
|
# entire Swarm stack invalid. The Kanban login endpoint fails closed until
|
||||||
|
# this value is configured.
|
||||||
|
OPERATOR_EMAIL: ${OPERATOR_EMAIL:-}
|
||||||
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
OPERATOR_PASSWORD: ${OPERATOR_PASSWORD:?set OPERATOR_PASSWORD}
|
||||||
PAYMENT_ADAPTER: fake
|
PAYMENT_ADAPTER: fake
|
||||||
FREIGHT_ADAPTER: fake
|
FREIGHT_ADAPTER: fake
|
||||||
|
|||||||
@@ -1217,6 +1217,8 @@ footer a:hover{color:var(--laranja2)}
|
|||||||
<button id="bPagar">Ir para o pagamento</button>
|
<button id="bPagar">Ir para o pagamento</button>
|
||||||
<button id="bMais" class="sec">Adicionar outro produto</button>
|
<button id="bMais" class="sec">Adicionar outro produto</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="checkoutStatus" class="avisoE" role="status" aria-live="polite"></p>
|
||||||
|
<div id="checkoutActions"></div>
|
||||||
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 90 dias e o histórico
|
<p class="obs">A nota fiscal sai no CNPJ informado. O arquivo fica guardado por 90 dias e o histórico
|
||||||
do pedido por 12 meses, para você repetir sem subir de novo.</p>
|
do pedido por 12 meses, para você repetir sem subir de novo.</p>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -2806,7 +2808,7 @@ $('bMais').addEventListener('click',()=>{
|
|||||||
|
|
||||||
$('bPagar').addEventListener('click',()=>{
|
$('bPagar').addEventListener('click',()=>{
|
||||||
if(window.dtfCheckout) window.dtfCheckout();
|
if(window.dtfCheckout) window.dtfCheckout();
|
||||||
else alert('Abra o Site pela stack local para criar um pedido de teste.');
|
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
|
||||||
});
|
});
|
||||||
pintaEntrega();
|
pintaEntrega();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
12
local/app.py
12
local/app.py
@@ -49,8 +49,12 @@ app.add_middleware(TrustedHostMiddleware, allowed_hosts=ALLOWED_HOSTS)
|
|||||||
|
|
||||||
@app.post('/api/operator/login')
|
@app.post('/api/operator/login')
|
||||||
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
def operator_login(body: OperatorLogin, request: Request, response: Response):
|
||||||
throttle('operator:'+body.username, request)
|
configured_email = os.environ.get('OPERATOR_EMAIL', '').strip().lower()
|
||||||
valid_user = secrets.compare_digest(body.username.encode(), os.environ['OPERATOR_USER'].encode())
|
if not configured_email:
|
||||||
|
raise HTTPException(503, 'Kanban operator email is not configured')
|
||||||
|
email = body.email
|
||||||
|
throttle('operator:'+email, request)
|
||||||
|
valid_user = secrets.compare_digest(email.encode(), configured_email.encode())
|
||||||
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
|
valid_password = secrets.compare_digest(body.password.encode(), os.environ['OPERATOR_PASSWORD'].encode())
|
||||||
if not (valid_user and valid_password):
|
if not (valid_user and valid_password):
|
||||||
audit('operator_login_failed')
|
audit('operator_login_failed')
|
||||||
@@ -60,10 +64,10 @@ def operator_login(body: OperatorLogin, request: Request, response: Response):
|
|||||||
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
previous = hashlib.sha256(request.cookies.get('dtf_operator','').encode()).hexdigest()
|
||||||
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
c.execute('DELETE FROM dtf_local.operator_sessions WHERE token_hash=%s', (previous,))
|
||||||
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
c.execute('INSERT INTO dtf_local.operator_sessions(token_hash,username) VALUES(%s,%s)',
|
||||||
(hashlib.sha256(token.encode()).hexdigest(), body.username))
|
(hashlib.sha256(token.encode()).hexdigest(), email))
|
||||||
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
response.set_cookie('dtf_operator', token, httponly=True, secure=COOKIE_SECURE,
|
||||||
samesite='strict', path='/api/operator', max_age=28800)
|
samesite='strict', path='/api/operator', max_age=28800)
|
||||||
audit('operator_login_success', operator=body.username)
|
audit('operator_login_success', operator=email)
|
||||||
return {'ok': True}
|
return {'ok': True}
|
||||||
|
|
||||||
@app.post('/api/operator/logout')
|
@app.post('/api/operator/logout')
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ try{
|
|||||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
||||||
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
const qid=await site.eval('localStorage.getItem("dtf-quote")');
|
||||||
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
const kanban=await page('http://localhost:'+(process.env.KANBAN_PORT||8081));
|
||||||
await kanban.fill('#user',process.env.OPERATOR_USER||'operator');
|
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
||||||
@@ -85,10 +85,10 @@ try{
|
|||||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
||||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
||||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Atualizar pedido local").click()');
|
await site.eval('window.dtfCheckout()');
|
||||||
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'approved quote displayed');
|
||||||
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido pago local").click()');
|
await site.eval('[...document.querySelectorAll("button")].find(x=>x.textContent==="Criar pedido de teste").click()');
|
||||||
await waitFor(async()=> (await site.text()).includes('Nenhuma cobrança real.'),'local payment');
|
await waitFor(async()=> (await site.text()).includes('Pedido #'),'test payment');
|
||||||
await kanban.click('#refresh');
|
await kanban.click('#refresh');
|
||||||
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
await waitFor(()=>kanban.eval(`board.orders.some(o=>o.quote_id===${JSON.stringify(qid)})`),'paid card');
|
||||||
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
const oid=await kanban.eval(`board.orders.find(o=>o.quote_id===${JSON.stringify(qid)}).id`);
|
||||||
|
|||||||
@@ -88,9 +88,17 @@ class Login(StrictModel):
|
|||||||
password: str = Field(min_length=1, max_length=128)
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
class OperatorLogin(StrictModel):
|
class OperatorLogin(StrictModel):
|
||||||
username: str = Field(min_length=1, max_length=100)
|
email: str = Field(min_length=3, max_length=254)
|
||||||
password: str = Field(min_length=1, max_length=128)
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
@field_validator('email')
|
||||||
|
@classmethod
|
||||||
|
def operator_email_valid(cls, value):
|
||||||
|
value = value.strip().lower()
|
||||||
|
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
|
||||||
|
raise ValueError('Invalid email')
|
||||||
|
return value
|
||||||
|
|
||||||
class FileReference(StrictModel):
|
class FileReference(StrictModel):
|
||||||
upload_id: UUID
|
upload_id: UUID
|
||||||
item_index: int = Field(ge=0, strict=True)
|
item_index: int = Field(ge=0, strict=True)
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ def run():
|
|||||||
print('PASS: CSP, frame protection, Host and cross-origin rejection')
|
print('PASS: CSP, frame protection, Host and cross-origin rejection')
|
||||||
|
|
||||||
operator=Client()
|
operator=Client()
|
||||||
credentials={'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
|
credentials={'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')}
|
||||||
encoded=base64.b64encode((credentials['username']+':'+credentials['password']).encode()).decode()
|
encoded=base64.b64encode((credentials['email']+':'+credentials['password']).encode()).decode()
|
||||||
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
|
raw('/api/operator/board',401,{'Authorization':'Basic '+encoded})
|
||||||
operator.call('/operator/login',credentials)
|
operator.call('/operator/login',credentials)
|
||||||
token=next(c for c in operator.jar if c.name=='dtf_operator')
|
token=next(c for c in operator.jar if c.name=='dtf_operator')
|
||||||
@@ -58,9 +58,9 @@ def run():
|
|||||||
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
|
print('PASS: extension allowlist, exact multipart Content-Length signature, pending upload quota')
|
||||||
|
|
||||||
# Unique identity avoids locking out the real local operator.
|
# Unique identity avoids locking out the real local operator.
|
||||||
attacker=Client();username='test-'+uuid4().hex
|
attacker=Client();email='test-'+uuid4().hex+'@example.test'
|
||||||
for _ in range(10):attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=401)
|
for _ in range(10):attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=401)
|
||||||
attacker.call('/operator/login',{'username':username,'password':'invalid'},expected=429)
|
attacker.call('/operator/login',{'email':email,'password':'invalid'},expected=429)
|
||||||
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
|
print('PASS: operator login throttling (only synthetic account bucket exhausted)')
|
||||||
|
|
||||||
if __name__=='__main__':run()
|
if __name__=='__main__':run()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class Client:
|
|||||||
if operator:
|
if operator:
|
||||||
if self.operator_client is None:
|
if self.operator_client is None:
|
||||||
self.operator_client=Client()
|
self.operator_client=Client()
|
||||||
self.operator_client.call('/operator/login',{'username':os.environ.get('OPERATOR_USER','operator'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
|
self.operator_client.call('/operator/login',{'email':os.environ.get('OPERATOR_EMAIL','operator@example.test'),'password':os.environ.get('OPERATOR_PASSWORD','local-operator-only')})
|
||||||
return self.operator_client.call(path,body,expected=expected)
|
return self.operator_client.call(path,body,expected=expected)
|
||||||
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
request=Request(BASE+'/api'+path,data=None if body is None else json.dumps(body).encode(),headers=headers)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
|
/* Checkout bridge only: approved commercial functions in dtf-site.html stay intact. */
|
||||||
(() => {
|
(() => {
|
||||||
const box = document.createElement('section');
|
const status = document.getElementById('checkoutStatus');
|
||||||
box.style.cssText = 'position:relative;z-index:20;background:#152026;color:#e6f4f7;padding:12px 24px;font:14px system-ui;border-bottom:2px solid #00b8da';
|
const actions = document.getElementById('checkoutActions');
|
||||||
box.innerHTML = '<b>Ambiente local · pagamento simulado</b> <span id="local-status" role="status"></span><div id="local-actions"></div>';
|
|
||||||
document.body.prepend(box);
|
|
||||||
const status = document.getElementById('local-status');
|
|
||||||
const actions = document.getElementById('local-actions');
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let draftId = localStorage.getItem('dtf-quote');
|
let draftId = localStorage.getItem('dtf-quote');
|
||||||
let requestKey = localStorage.getItem('dtf-request-key');
|
let requestKey = localStorage.getItem('dtf-request-key');
|
||||||
@@ -15,7 +11,10 @@
|
|||||||
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
|
credentials: 'same-origin', headers: {'Content-Type':'application/json'},
|
||||||
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
|
...(body === undefined ? {} : {method:'POST', body:JSON.stringify(body)})
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const text = await response.text();
|
||||||
|
let data;
|
||||||
|
try { data = text ? JSON.parse(text) : {}; }
|
||||||
|
catch (_) { throw new Error(response.ok ? 'Resposta inválida do serviço.' : 'O serviço está indisponível. Tente novamente em instantes.'); }
|
||||||
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
|
if (!response.ok) { const error=new Error(typeof data.detail === 'string' ? data.detail : 'Confira os dados do pedido ('+response.status+').');error.status=response.status;throw error; }
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
@@ -23,7 +22,7 @@
|
|||||||
window.dtfSessionReady=ready;
|
window.dtfSessionReady=ready;
|
||||||
window.dtfApi=api;
|
window.dtfApi=api;
|
||||||
ready.catch(error => { status.textContent = error.message; });
|
ready.catch(error => { status.textContent = error.message; });
|
||||||
function message(text) { status.textContent = ' · '+text; }
|
function message(text) { status.textContent = text; }
|
||||||
function button(label, handler) {
|
function button(label, handler) {
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.textContent = label;
|
el.textContent = label;
|
||||||
@@ -43,13 +42,13 @@
|
|||||||
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
|
if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
|
||||||
entrega.valor = result.total_cents/100;
|
entrega.valor = result.total_cents/100;
|
||||||
entrega.cotado = true;
|
entrega.cotado = true;
|
||||||
$('cepMsg').textContent = 'Frete simulado local: '+rs(entrega.valor)+'. Nenhuma transportadora foi consultada.';
|
$('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.';
|
||||||
pintaEntrega();
|
pintaEntrega();
|
||||||
} catch(error) { $('cepMsg').textContent = error.message; }
|
} catch(error) { $('cepMsg').textContent = error.message; }
|
||||||
};
|
};
|
||||||
window.dtfCheckout = async () => {
|
window.dtfCheckout = async () => {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
if (draftId) { await refresh(); box.scrollIntoView(); return; }
|
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||||
if (!clienteOk() || !entrega.cotado) return;
|
if (!clienteOk() || !entrega.cotado) return;
|
||||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||||
@@ -75,7 +74,7 @@
|
|||||||
const quote = await api('/quotes',{request_key:requestKey,...content});
|
const quote = await api('/quotes',{request_key:requestKey,...content});
|
||||||
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
draftId=quote.id; localStorage.setItem('dtf-quote',draftId);
|
||||||
await refresh();
|
await refresh();
|
||||||
box.scrollIntoView({behavior:'smooth'});
|
status.scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||||
} catch(error) { message(error.message); }
|
} catch(error) { message(error.message); }
|
||||||
finally { busy=false; pintaEntrega(); }
|
finally { busy=false; pintaEntrega(); }
|
||||||
};
|
};
|
||||||
@@ -89,19 +88,23 @@
|
|||||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||||
} else if (quote.status==='approved') {
|
} 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.');
|
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||||
button('Criar pedido pago local',async event=>{
|
if ((await ready).environment !== 'local') {
|
||||||
|
message('Cotação revisada. O pagamento online ainda não está disponível.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button('Criar pedido de teste',async event=>{
|
||||||
event.target.disabled=true;
|
event.target.disabled=true;
|
||||||
try {
|
try {
|
||||||
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
const order=await api('/orders/dev-paid',{quote_id:draftId});
|
||||||
pedido=[]; itemAtual=null; limpaPaineis();
|
pedido=[]; itemAtual=null; limpaPaineis();
|
||||||
await window.dtfClearCart?.();
|
await window.dtfClearCart?.();
|
||||||
message('Pedido local #'+order.number+' pago e disponível no Kanban.');
|
message('Pedido #'+order.number+' criado e disponível no Kanban.');
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch(error) { message(error.message); event.target.disabled=false; }
|
} catch(error) { message(error.message); event.target.disabled=false; }
|
||||||
});
|
});
|
||||||
} else if (quote.status==='paid') {
|
} else if (quote.status==='paid') {
|
||||||
message('Pedido local #'+quote.order.number+' pago · etapa: '+quote.order.state+'. Nenhuma cobrança real.');
|
message('Pedido #'+quote.order.number+' · etapa: '+quote.order.state+'.');
|
||||||
button('Novo pedido local',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
button('Novo pedido',()=>{localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');localStorage.removeItem('dtf-request-body');location.reload();});
|
||||||
} else {
|
} else {
|
||||||
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
message('Cotação expirada. Envie o carrinho para uma nova revisão.');
|
||||||
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
button('Nova cotação',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||||
@@ -109,17 +112,9 @@
|
|||||||
} catch(error) {
|
} catch(error) {
|
||||||
message(error.message);
|
message(error.message);
|
||||||
actions.replaceChildren();
|
actions.replaceChildren();
|
||||||
button('Limpar referência local e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
button('Limpar referência e tentar de novo',()=>{draftId=null;requestKey=null;localStorage.removeItem('dtf-quote');localStorage.removeItem('dtf-request-key');actions.replaceChildren();});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Explicit refresh avoids replacing focused payment controls during interaction.
|
|
||||||
const refreshButton = document.createElement('button');
|
|
||||||
refreshButton.textContent='Atualizar pedido local';
|
|
||||||
refreshButton.style.cssText='margin-left:12px;padding:6px;cursor:pointer';
|
|
||||||
refreshButton.onclick=refresh;
|
|
||||||
box.append(refreshButton);
|
|
||||||
const portalLink=document.createElement('a');portalLink.href='/portal.html';portalLink.textContent='Minha conta e pedidos';
|
|
||||||
portalLink.style.cssText='color:#e6f4f7;margin-left:16px;text-decoration:underline';box.append(portalLink);
|
|
||||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){draftId=quoteFromPortal;localStorage.setItem('dtf-quote',draftId);}
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:gri
|
|||||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<header><h1>Kanban DTF</h1><span class="meta">Desenvolvimento local</span><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||||
<div class="aviso">Pagamentos, Tiny/Olist, WhatsApp e frete são simulados. 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.</div>
|
<div class="aviso">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.</div>
|
||||||
<form id="login"><label>Usuário <input id="user" autocomplete="username" required></label><label>Senha local <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||||
<p id="status" role="status"></p>
|
<p id="status" role="status"></p>
|
||||||
<section id="reviews"></section><div id="kan"></div>
|
<section id="reviews"></section><div id="kan"></div>
|
||||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ async function move(order,state){
|
|||||||
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
let reason='';if(state==='cor'){reason=prompt('Motivo da correção:');if(!reason)return;}
|
||||||
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
await api('/orders/'+order.id+'/move',{state,version:order.version,reason});await load();
|
||||||
}
|
}
|
||||||
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{username:$('user').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
$('login').onsubmit=async e=>{e.preventDefault();try{await api('/login',{email:$('email').value,password:$('password').value});await load();}catch(error){$('status').textContent=error.message;}finally{$('password').value='';}};
|
||||||
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
$('logout').onclick=async()=>{await api('/logout',{});for(const key of Object.keys(localStorage))if(key.startsWith('dtf-'))localStorage.removeItem(key);location.reload();};
|
||||||
$('refresh').onclick=load;
|
$('refresh').onclick=load;
|
||||||
load();
|
load();
|
||||||
|
|||||||
Reference in New Issue
Block a user