Accept isolated WORKSHOP-RESOURCE-RECORD-v1 delivery

Record explicit workshop acceptance and preserve subsequent synthetic work without accepting receivables. Keep 080000/070000 unapplied, real workshop grants pending and operational issuance blocks intact. Exclude local credentials and real-data preservation reports.
This commit is contained in:
2026-10-27 11:28:34 +02:00
parent 4276aca5c2
commit 21a91f2aff
47 changed files with 2774 additions and 6 deletions
@@ -0,0 +1,53 @@
import React, { useEffect, useRef, useState } from 'react';
const titles = { obligation:'Obrigação de teste', receipt:'Recebimento proposto', observation:'Observação simulada', correction:'Correcção proposta' };
const actions = { reverse_allocation:'Reverter afectação integral', reverse_reconcile:'Desfazer conferência', reverse_receipt:'Reverter recebimento integral', reduction:'Aplicar redução sintética', due:'Corrigir vencimento' };
const effects = { open:'Obrigação aberta', receipt_confirm:'Recebimento confirmado', refused:'Proposta recusada', allocate:'Afectação aplicada', reconcile:'Reconciliado em simulação', divergence:'Divergência preservada', reverse_allocation:'Afectação revertida', reverse_reconcile:'Conferência desfeita', reverse_receipt:'Recebimento revertido', reduction:'Redução sintética aplicada', due:'Vencimento corrigido' };
const money = v => String(v ?? '').replace('.', ',');
export default function ServiceReceivablesWorkspace({context, can, api, busy}) {
const [data,setData]=useState(null), [error,setError]=useState(''), [working,setWorking]=useState(false), [obId,setObId]=useState(''), [proof,setProof]=useState(null);
const attempt=useRef(null);
const proposalIntent=useRef(null);
const allowed = r => can(`service_receivables.${r}`,context.unit?.id ?? null);
const load = async () => { const next=await api('/api/service-receivables'); setData(next); return next; };
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
const ob=data?.obligations.find(o=>String(o.id)===String(obId)) ?? data?.obligations[0];
const record=id=>data?.records.find(r=>r.id===Number(id));
const origin=ob && record(ob.id);
const receipts=ob ? Object.values(ob.receipts) : [];
const disabled=busy||working;
async function send(path,input) {
setError('');setWorking(true);
const canonical=JSON.stringify([path,input]);
if(attempt.current?.canonical!==canonical) attempt.current={canonical,key:crypto.randomUUID()};
try { await api(`/api/service-receivables${path}`,'POST',input,context.token,attempt.current.key); await load(); attempt.current=null; proposalIntent.current=null; }
catch(e) { setError(e.message); }
finally {setWorking(false);}
}
function propose(kind,values) {
const invoice=kind==='obligation'?Number(values.invoice_id):origin.invoice_id;
const signature=JSON.stringify([kind,invoice,values]);
if(proposalIntent.current?.signature!==signature) proposalIntent.current={signature,id:crypto.randomUUID()};
return send('/proposals',{kind,invoice_id:invoice,currency:'MZN',source:'TEST-MANUAL',external_id:values.external_id || proposalIntent.current.id,revision:kind==='obligation'?0:ob.revision,...(kind==='obligation'?{}:{obligation_id:ob.id}),...values});
}
const pending = r => !ob?.effects.some(e=>e.record_id===r.id && e.kind!=='allocate');
return <>
<section className="panel"><p className="eyebrow">SGF-RECEIVABLES-SIM-v1</p><h1>Contas a receber — simulação</h1><div className="alert error"><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p></div><p>Liquidar um documento e conferir um recebimento são operações distintas. Sem recibo fiscal, PDF novo, banco ou contabilidade.</p>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar dados explicitamente</button><p className="help">A vista conserva a revisão apresentada. Alterações entretanto feitas por outro utilizador provocam recusa e exigem actualização; os formulários não substituem valores silenciosamente.</p></section>
{!data ? <p>A carregar…</p> : data.blocked ? <section className="panel"><h2>Percurso real indisponível</h2><p>Este incremento só funciona na instância isolada de testes. As permissões não retiram o bloqueio.</p></section> : <>
{allowed('prepare')&&<section className="panel"><h2>Preparar abertura explícita</h2><SmallForm submit="Propor obrigação" disabled={disabled} onSubmit={v=>propose('obligation',{invoice_id:Number(v.invoice_id),due_date:v.due_date||null})}><label>Documento exacto<select name="invoice_id" required>{data.documents.map(d=><option key={d.id} value={d.id}>#{d.id} · {d.series_code}/{d.number}</option>)}</select></label><label>Vencimento conhecido (opcional)<input type="date" name="due_date"/></label></SmallForm></section>}
<section className="panel"><h2>Obrigações e confirmação</h2>{data.obligations.length===0&&<p>Nenhuma obrigação aberta ou proposta. Documentos e rascunhos não criam dívida automaticamente.</p>}<label>Obrigação<select value={ob?.id||''} onChange={e=>{setObId(e.target.value);setProof(null);}}>{data.obligations.map(o=><option key={o.id} value={o.id}>#{o.id} · {record(o.id)?.external_id} · {o.state==='proposed'?'Aguarda confirmação':o.state==='refused'?'Recusada':o.state==='settled'?'Liquidada':'Em aberto'}</option>)}</select></label>{ob&&<><p>Documento #{origin.invoice_id} · Cliente #{origin.client_id} · MZN · Revisão {ob.revision} · Corte {ob.cut_date}</p><div className="summary-grid">{[['Original',ob.original],['Redução sintética',ob.reduced],['Recebido confirmado',ob.received],['Afectado',ob.allocated],['Por afectar',ob.available],[ob.opened?'Saldo por receber':'Valor proposto — obrigação não aberta',ob.balance]].map(([label,value])=><section key={label}><span>{label}</span><h2>{money(value)} MZN</h2></section>)}</div><p>Vencimento: {ob.due_date||'por definir'} · {({not_open:'Obrigação ainda não aberta',unknown:'Pendência — atraso não determinado',overdue:'Saldo vencido',not_due:'Ainda não vencido',settled:'Liquidada'})[ob.due_state]}</p>{pending(origin)&&allowed('confirm')&&<Decision record={origin} disabled={disabled} decide={decision=>send(`/${origin.id}/decision`,{version:origin.version,revision:ob.revision,decision,reason:'Decisão explícita sobre proposta sintética.'})}/>}</>}</section>
{ob&&ob.opened&&<>
{allowed('prepare')&&<section className="panel"><h2>Registar recebimento proposto</h2><SmallForm submit="Propor recebimento" disabled={disabled} onSubmit={v=>propose('receipt',v)}><label>Valor recebido (MZN; ponto decimal)<input name="amount" inputMode="decimal" placeholder="400.00" required/></label><label>Data declarada<input name="date" type="date" required/></label><label>Identificador do acontecimento sintético<input name="external_id" required/></label><label>Evidência sintética<textarea name="evidence" required/></label></SmallForm></section>}
<section className="panel"><h2>Recebimentos e afectações</h2>{receipts.map(rc=>{const r=record(rc.id); return <article className="panel" key={rc.id}><h3>Recebimento #{rc.id} · {r.external_id}</h3><p>{({proposed:'Aguarda confirmação',confirmed:'Confirmado em teste',refused:'Recusado',reversed:'Revertido'})[rc.state]} · Recebido {money(rc.amount)} · Afectado {money(rc.allocated)} · Por afectar {money(rc.available)} MZN</p><p>{rc.reconciled?'Reconciliado em simulação':'Sem reconciliação simulada confirmada'}</p><button onClick={()=>setProof(rc.id)}>Consultar comprovativo interno</button>{pending(r)&&allowed('confirm')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Confirmação de evidência exclusivamente sintética.'})}/ >}{rc.state==='confirmed'&&allowed('allocate')&&<SmallForm submit="Afectar explicitamente" disabled={disabled} onSubmit={v=>send(`/${rc.id}/allocate`,{amount:v.amount,version:r.version,revision:ob.revision})}><label>Montante a afectar a este documento<input name="amount" inputMode="decimal" required/></label></SmallForm>}</article>;})}</section>
{proof&&<section className="panel"><h2>Comprovativo interno #{proof}</h2><strong>TESTE — SEM VALIDADE FISCAL</strong><p>SIMULAÇÃO — SEM MOVIMENTAÇÃO DE DINHEIRO</p><p>Documento #{origin.invoice_id} · Recebimento #{proof} · Revisão {ob.revision} · Corte {ob.cut_date}</p><p>Evidência: {record(proof)?.data.evidence} · Data declarada: {record(proof)?.data.date}</p><p>Recebido {money(ob.receipts[proof]?.amount)} · Afectado {money(ob.receipts[proof]?.allocated)} · Por afectar {money(ob.receipts[proof]?.available)} MZN</p><p>Estado actual: {ob.receipts[proof]?.state}. Consulta interna, sem emissão ou entrega de recibo fiscal.</p>{ob.effects.filter(e=>e.receipt_id===proof||e.record_id===proof).map(e=><p key={e.id}>Efeito #{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id}</p>)}</section>}
{allowed('prepare')&&<section className="panel"><h2>Preparar observação de fonte simulada</h2><SmallForm submit="Guardar observação sintética" disabled={disabled} onSubmit={v=>propose('observation',{...v,receipt_id:Number(v.receipt_id)})}><label>Recebimento exacto<select name="receipt_id">{receipts.filter(r=>r.state==='confirmed').map(r=><option key={r.id} value={r.id}>#{r.id} · {record(r.id)?.external_id}</option>)}</select></label><label>Valor observado (MZN)<input name="amount" required/></label><label>Data da observação<input name="date" type="date" required/></label><label>ID da observação sintética<input name="external_id" required/></label><label>Evidência artificial<textarea name="evidence" required/></label></SmallForm></section>}
<section className="panel"><h2>Conferência das observações</h2>{data.records.filter(r=>r.kind==='observation'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>Observação #{r.id} → Recebimento #{r.receipt_id}</h3><p>{r.external_id} · {money(r.amount)} MZN · {r.data.date}</p>{pending(r)&&allowed('reconcile')&&<button disabled={disabled} onClick={()=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision:'approved',reason:'Conferência explícita da observação simulada.'})}>Conferir correspondência exacta</button>}</article>)}</section>
{allowed('correction_request')&&<section className="panel"><h2>Propor correcção de teste</h2><SmallForm submit="Submeter correcção para validação" disabled={disabled} onSubmit={v=>{const d={action:v.action,reason:v.reason}; if(v.action==='reduction')Object.assign(d,{amount:v.amount,document_ref:v.document_ref,line:'1'}); else if(v.action==='due')d.due_date=v.due_date||null; else if(v.action==='reverse_receipt')d.receipt_id=Number(v.receipt_id); else d.target_effect_id=Number(v.target_effect_id); return propose('correction',d);}}><label>Operação<select name="action">{Object.entries(actions).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label><label>Efeito exacto a reverter<select name="target_effect_id">{ob.effects.filter(e=>['allocate','reconcile'].includes(e.kind)).map(e=><option key={e.id} value={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)}</option>)}</select></label><label>Recebimento para reversão integral<select name="receipt_id">{receipts.map(r=><option key={r.id} value={r.id}>#{r.id}</option>)}</select></label><label>Valor da redução (se aplicável)<input name="amount" placeholder="100.00"/></label><label>Documento redutor sintético (se aplicável)<input name="document_ref" placeholder="TEST-REDUCTION-..."/></label><label>Novo vencimento (se aplicável)<input name="due_date" type="date"/></label><label>Motivo explícito<textarea name="reason" required/></label><p className="help">Apenas os campos da operação escolhida serão enviados. Redução refere a linha 1 do documento original.</p></SmallForm></section>}
<section className="panel"><h2>Propostas de correcção</h2>{data.records.filter(r=>r.kind==='correction'&&r.obligation_id===ob.id).map(r=><article key={r.id}><h3>#{r.id} · {actions[r.data.action]}</h3><p>{r.data.reason} · Revisão proposta {r.data.revision} · {r.data.document_ref}</p>{pending(r)&&allowed('correction_approve')&&<Decision record={r} disabled={disabled} decide={decision=>send(`/${r.id}/decision`,{version:r.version,revision:ob.revision,decision,reason:'Decisão explícita da correcção operacional sintética.'})}/>}</article>)}</section>
<section className="panel"><h2>Histórico imutável</h2>{ob.effects.map(e=><p key={e.id}>#{e.id} · {effects[e.kind]} · {money(e.amount)} MZN · Autor #{e.actor_id} · {e.created_at}{e.target_effect_id&&` · Reverte efeito #${e.target_effect_id}`}</p>)}</section>
</>}
</>}
</>;
}
function SmallForm({children,onSubmit,submit,disabled}) {return <form onSubmit={e=>{e.preventDefault();onSubmit(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{submit}</button></form>;}
function Decision({disabled,decide}) {return <div className="actions"><button className="primary" disabled={disabled} onClick={()=>decide('approved')}>Confirmar proposta</button><button disabled={disabled} onClick={()=>decide('refused')}>Recusar proposta</button></div>;}
+56
View File
@@ -0,0 +1,56 @@
import React, { useEffect, useRef, useState } from 'react';
const kindLabels={assignment:'Responsável atribuído',task:'Tarefa',authorization:'Autorização registada',start:'Início autorizado',time:'Tempo relatado',material:'Material observado'};
const eventLabels={required:'Requisitado',delivered:'Entregue',consumed:'Aplicado/consumido',returned:'Devolvido'};
const decisionLabels={verified:'Verificado',refused:'Recusado',acknowledged:'Ocorrência reconhecida para auditoria — continua fora do âmbito'};
const iso=v=>new Date(v).toISOString();
export default function WorkshopWorkspace({context,can,api,busy}) {
const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
const attempt=useRef(null);
const allowed=r=>can(`workshop.${r}`,context.unit?.id??null);
const load=async()=>{const d=await api('/api/workshop');setData(d);return d;};
useEffect(()=>{load().catch(e=>setError(e.message));},[context.token]);
const order=data?.orders.find(o=>String(o.id)===String(orderId))??data?.orders[0];
const tasks=order?.entries.filter(e=>e.kind==='task'&&e.current)??[];
const task=tasks.find(e=>String(e.id)===String(taskId))??tasks[0];
const authorizations=order?.entries.filter(e=>e.kind==='authorization'&&e.current&&e.task_id===task?.id)??[];
const auth=authorizations.at(-1);
const starts=order?.entries.filter(e=>e.kind==='start'&&!order.entries.some(t=>t.start_id===e.id))??[];
const disabled=busy||working;
async function send(path,input) {
setWorking(true);setError(''); const canonical=JSON.stringify([path,input]);
if(attempt.current?.canonical!==canonical)attempt.current={canonical,key:crypto.randomUUID()};
try{await api('/api/workshop'+path,'POST',input,context.token,attempt.current.key);await load();attempt.current=null;}
catch(e){setError(e.message);}finally{setWorking(false);}
}
function entry(kind,values) {
const d={...values};
if(d.previous_id) {
const previous=order.entries.find(e=>e.id===Number(d.previous_id));
if(!previous){setError('Seleccione uma origem existente desta ordem.');return;}
d.previous_id=previous.id; d.origin=previous.origin; d.version=previous.version+1;
} else {delete d.previous_id;d.version=1;}
return send(`/orders/${order.id}/entries`,{kind,revision:order.revision,...d});
}
const correctionFields=kind=><><label>Versão anterior a corrigir (opcional)<select name="previous_id"><option value="">Novo acontecimento</option>{order.entries.filter(e=>e.kind===kind&&e.current).map(e=><option key={e.id} value={e.id}>#{e.id} · {e.origin} v{e.version}</option>)}</select></label><label>Motivo de correcção (se aplicável)<input name="correction_reason"/></label></>;
return <>
<section className="panel"><p className="eyebrow">WORKSHOP-RESOURCE-RECORD-v1</p><h1>Oficina — registo de recursos</h1><div className="alert error"><strong>TESTE — SEM UTILIZAÇÃO OPERACIONAL</strong><p>Sem stock, custos, facturação ou recebimentos.</p></div>{error&&<p role="alert" className="alert error">{error}</p>}<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar revisão explicitamente</button><p className="help">Abertura não autoriza trabalho. Relatar uma ocorrência passada não lhe dá autorização retroactiva. Alterações concorrentes exigem nova revisão.</p></section>
{!data?<p>A carregar…</p>:data.blocked?<section className="panel"><h2>Utilização operacional bloqueada</h2><p>Disponível apenas na instância isolada ligada à base de testes.</p></section>:<>
{allowed('open')&&<section className="panel"><h2>Abrir ordem de teste</h2><Form label="Abrir ordem" disabled={disabled} save={v=>send('/orders',{...v,client_id:Number(v.client_id)})}><label>Referência operacional<input name="reference" required/></label><label>Cliente existente<select name="client_id">{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><label>Identificação individual do equipamento<input name="equipment_identifier" required/></label><label>Descrição / marca / modelo<input name="equipment_description" required/></label><label>Número de série (se conhecido)<input name="serial"/></label><label>Pedido / avaria<textarea name="request" required/></label><label>Localização<input name="location" required/></label><label>Evidência do pedido<input name="evidence" required/></label></Form><p className="help">Para reutilizar equipamento já identificado, a API aceita o seu ID; não criar outro só por existir uma nova ordem.</p></section>}
<section className="panel"><h2>Ordens autorizadas para consulta</h2><label>Ordem<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setTaskId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>{order?<><p>Cliente #{order.client_id} · {order.equipment.identifier} · {order.equipment.description} · {order.location}</p><p>{order.request}</p><p>Revisão {order.revision} · Responsável #{order.assigned_to??'por atribuir'} · {({open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito para revisão',records_verified:'Registos verificados — sem conclusão comercial'})[order.state]}</p><div className="summary-grid"><div><span>Tempo efectivo relatado</span><h2>{order.minutes_reported} MIN</h2></div><div><span>Tempo efectivo verificado</span><h2>{order.minutes_verified} MIN</h2></div></div></>:<p>Sem ordens visíveis neste âmbito/atribuição.</p>}</section>
{order&&<>
{allowed('assign')&&<section className="panel"><h2>Atribuir responsável</h2><Form label="Atribuir técnico" disabled={disabled} save={v=>entry('assignment',{origin:v.origin,technician_id:Number(v.technician_id)})}><label>Referência da atribuição<input name="origin" required/></label><label>Técnico com direito próprio<select name="technician_id">{data.technicians.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.name}</option>)}</select></label></Form></section>}
<section className="panel"><h2>Tarefa e âmbito</h2><label>Versão da tarefa<select value={task?.id??''} onChange={e=>setTaskId(e.target.value)}>{tasks.map(t=><option key={t.id} value={t.id}>#{t.id} · {t.data.description} · v{t.version}</option>)}</select></label><p>{auth?`Autorização #${auth.id}: ${auth.data.authorized_by} · ${auth.data.authorized_at} · ${auth.data.scope}`:'Sem autorização demonstrada para esta versão.'}</p></section>
{allowed('record')&&<section className="panel"><h2>Registar tarefa ou nova versão</h2><Form label="Guardar tarefa" disabled={disabled} save={v=>entry('task',v)}><label>Identificador de origem da tarefa<input name="origin" required/></label><label>Descrição da tarefa<textarea name="description" required/></label>{correctionFields('task')}</Form></section>}
{allowed('authorize')&&task&&<section className="panel"><h2>Registar autorização do cliente</h2><Form label="Registar autorização" disabled={disabled} save={v=>entry('authorization',{...v,task_id:task.id,authorized_at:iso(v.authorized_at)})}><label>Identificador da evidência de autorização<input name="origin" required/></label><label>Quem autorizou<input name="authorized_by" required/></label><label>Qualidade / representação do cliente<input name="capacity" required/></label><label>Data/hora da autorização do cliente<input type="datetime-local" name="authorized_at" required/></label><label>Canal<input name="channel" required/></label><label>Trabalho abrangido nesta versão<textarea name="scope" required/></label><label>Referência do comprovativo ou contrato<input name="evidence" required/></label>{correctionFields('authorization')}</Form><p className="help">O autor deste registo é distinto da pessoa do cliente que autorizou. Nova autorização não limpa ocorrências anteriores fora do âmbito.</p></section>}
{allowed('record')&&task&&<>
<section className="panel"><h2>Início de trabalho</h2><Form label="Iniciar trabalho autorizado" disabled={disabled} save={v=>entry('start',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{})})}><label>Referência do início<input name="origin" required/></label><label>Evidência do início<input name="evidence" required/></label></Form><p>Sem autorização compatível, o servidor recusa iniciar. Uma ocorrência passada pode ser relatada no formulário seguinte, sem se tornar autorizada.</p></section>
<section className="panel"><h2>Relatar tempo efectivo</h2><Form label="Guardar apontamento de tempo" disabled={disabled} save={v=>{if(Boolean(v.pause_start)!==Boolean(v.pause_end)){setError('Indique início e fim da pausa; não será ignorada.');return;} const d={origin:v.origin,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),mode:v.start_id?'started':'reported',time_type:v.time_type,starts_at:iso(v.starts_at),ends_at:iso(v.ends_at),evidence:v.evidence,previous_id:v.previous_id,correction_reason:v.correction_reason,pauses:[]}; if(v.pause_start&&v.pause_end)d.pauses=[{start:iso(v.pause_start),end:iso(v.pause_end)}]; if(v.start_id)d.start_id=Number(v.start_id);return entry('time',d);}}><label>Identificador da ocorrência de tempo<input name="origin" required/></label><label>Início registado (opcional)<select name="start_id"><option value="">Relato de ocorrência já acontecida</option>{starts.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.starts_at} · tarefa #{s.task_id}</option>)}</select></label><label>Tipo de tempo<select name="time_type"><option value="work">Trabalho</option><option value="travel">Deslocação</option></select></label><label>Início efectivo<input type="datetime-local" name="starts_at" required/></label><label>Fim efectivo<input type="datetime-local" name="ends_at" required/></label><label>Início da pausa (opcional)<input type="datetime-local" name="pause_start"/></label><label>Fim da pausa (opcional)<input type="datetime-local" name="pause_end"/></label><label>Evidência da ocorrência<textarea name="evidence" required/></label>{correctionFields('time')}</Form><p className="help">A API suporta várias pausas; este formulário inicial recebe uma. Intervalos incompletos ou não representáveis exactamente em minutos são recusados.</p></section>
<section className="panel"><h2>Registar material observado</h2><Form label="Guardar apontamento de material" disabled={disabled} save={v=>entry('material',{...v,task_id:task.id,...(auth?{authorization_id:auth.id}:{}),starts_at:iso(v.starts_at)})}><label>Identificador da ocorrência material<input name="origin" required/></label><label>Referência estável do material<input name="resource" required/></label><label>Ocorrência<select name="event">{Object.entries(eventLabels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Propriedade<select name="owner"><option value="company">Empresa</option><option value="client">Cliente</option></select></label><label>Unidade de medida<select name="unit">{['UN','KG','M','L'].map(u=><option key={u}>{u}</option>)}</select></label><label>Quantidade observada<input name="quantity" inputMode="decimal" required/></label><label>Data/hora da ocorrência<input name="starts_at" type="datetime-local" required/></label><label>Evidência de origem<textarea name="evidence" required/></label>{correctionFields('material')}</Form><p>Sem reserva, armazém, custo ou saída de stock. Divergência física fica visível para revisão.</p></section>
</>}
<section className="panel"><h2>Materiais por referência, propriedade e unidade</h2>{order.materials.map(m=><article key={m.resource+m.owner+m.unit}><h3>{m.resource} · {m.unit} · {m.owner==='client'?'Cliente':'Empresa'}</h3><p>Requisitado {m.required} · Entregue {m.delivered} · Aplicado {m.consumed} · Devolvido {m.returned} · Aplicação verificada {m.verified_consumed}</p><p>Diferença física a explicar: {m.unexplained} {m.unit}. São observações, não saldo de armazém.</p></article>)}</section>
<section className="panel"><h2>Apontamentos, versões e verificação</h2>{order.entries.map(e=><article className="panel" key={e.id}><h3>#{e.id} · {kindLabels[e.kind]} · {e.origin} v{e.version}{!e.current?' · Histórico substituído':''}</h3><p>{e.data.description||e.data.scope||e.data.evidence} · Autor #{e.actor_id}</p>{e.minutes!=null&&<p>{e.starts_at} a {e.ends_at} · {e.minutes} MIN efectivos · {e.data.time_type==='travel'?'Deslocação':'Trabalho'}</p>}{e.quantity!=null&&<p>{eventLabels[e.data.event]} · {e.quantity} {e.data.unit} · {e.data.resource}</p>}{e.outside_authority&&<div className="alert error">FORA DO ÂMBITO AUTORIZADO — relato para auditoria. Não autoriza execução nem ganha cobertura retroactiva.</div>}<p>{e.decision?`${decisionLabels[e.decision.decision]} · ${e.decision.reason} · Verificador #${e.decision.actor_id}`:e.current&&['task','time','material'].includes(e.kind)?'Aguarda verificação explícita.':''}</p>{allowed('verify')&&e.current&&!e.decision&&['task','time','material'].includes(e.kind)&&<Form label={e.outside_authority?'Reconhecer ocorrência para auditoria':'Verificar apontamento'} disabled={disabled} save={v=>send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:e.outside_authority?'acknowledged':'verified',reason:v.reason})}><label>Fundamento da revisão<input name="reason" required/></label><button type="button" disabled={disabled} onClick={event=>{const form=event.currentTarget.form;if(!form.reportValidity())return;send(`/entries/${e.id}/verification`,{version:e.version,revision:order.revision,decision:'refused',reason:String(new FormData(form).get('reason'))});}}>Recusar apontamento</button></Form>}</article>)}</section>
</>}
</>}
</>;
}
function Form({children,label,disabled,save}) {return <form onSubmit={e=>{e.preventDefault();save(Object.fromEntries(new FormData(e.currentTarget)));}}>{children}<button className="primary" disabled={disabled}>{label}</button></form>;}
+5 -1
View File
@@ -7,6 +7,8 @@ import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
const labels = { 'catalogue_accounting.view':'Consultar classificação contabilística', 'catalogue_accounting.propose':'Preparar classificação contabilística', 'catalogue_accounting.approve':'Validar ou recusar classificação contabilística', 'company.view': 'Consultar empresa', 'company.edit': 'Editar nome da empresa', 'unit.create': 'Criar unidades', 'access.manage': 'Gerir acessos', 'unit.view': 'Consultar unidade', 'unit.edit': 'Editar nome da unidade' };
@@ -51,7 +53,7 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...((can('issuer_documents.view') || can('client_documents.view') || can('draft_documents.view', context?.unit?.id ?? null)) ? [['documents', 'Preparação documental'], ...(can('issuer_documents.view') ? [['preview_model', 'Apresentação do rascunho']] : [])] : []), ...(can('workshop.view',context?.unit?.id ?? null) ? [['workshop', 'Oficina de teste']] : []), ...(can('service_receivables.view',context?.unit?.id ?? null) ? [['service_receivables', 'Recebimentos simulados']] : []), ...(can('service_invoices.view',context?.unit?.id ?? null) ? [['service_invoices', 'Facturas de teste']] : []), ...(can('service_fiscal.view') ? [['service_fiscal', 'Perfil fiscal do servi\u00e7o']] : []), ...(can('catalogue_accounting.view') ? [['catalogue_accounting', 'Classificação contabilística']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
@@ -61,6 +63,8 @@ function App() {
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{page === 'preview_model' && <DocumentPreviewConfiguration context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'documents' && <DocumentPreparationWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} />}
{page === 'service_invoices' && <ServiceInvoiceWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'service_fiscal' && <ServiceFiscalWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}