first commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import React, {useEffect,useRef,useState} from 'react';
|
||||
const decisionLabel={approved:'Validado',refused:'Recusado'};
|
||||
const money=v=>v===null?'Pendente':`${v} MZN`;
|
||||
export default function DirectCostsWorkspace({context,can,api,busy}) {
|
||||
const [data,setData]=useState(null),[error,setError]=useState(''),[working,setWorking]=useState(false),[orderId,setOrderId]=useState('');
|
||||
const [selection,setSelection]=useState({}),[sourceId,setSourceId]=useState('');const retry=useRef(null);
|
||||
const allowed=r=>can(`direct_costs.${r}`,context.unit?.id??null);
|
||||
const load=async()=>{const d=await api('/api/direct-costs');setData(d);return d;};
|
||||
useEffect(()=>{setSelection({});load().catch(e=>setError(e.message));},[context.token]);
|
||||
const order=data?.orders.find(o=>String(o.id)===orderId)??data?.orders[0];
|
||||
async function send(path,input){setWorking(true);setError('');const signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};
|
||||
try{await api(path,'POST',input,context.token,retry.current.key);retry.current=null;await load();}catch(e){setError(e.message);}finally{setWorking(false);}}
|
||||
const disabled=busy||working;
|
||||
const latest=order?.calculations.at(-1);
|
||||
const source=order?.sources.find(s=>String(s.id)===sourceId)??order?.sources[0];
|
||||
const decide=(kind,row,decision,reason)=>send(`/api/direct-costs/orders/${order.id}/decisions`,{kind,id:row.id,version:row.version,decision,reason});
|
||||
return <section className="workspace"><p className="eyebrow">ENSAIO DE CUSTO DIRECTO</p><h2>Custos directos da ordem</h2><div className="alert">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Custo directo conhecido; não é custo completo ou margem. Sem stock, IVA, lançamentos ou facturação.</div>
|
||||
{error&&<p role="alert" className="alert error">{error}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>O custeio permanece bloqueado nesta instância. Use apenas a demonstração isolada.</p>:<>
|
||||
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar versões explicitamente</button>
|
||||
<label>Ordem de teste<select value={order?.id??''} onChange={e=>{setOrderId(e.target.value);setSelection({});setSourceId('');}}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>
|
||||
{!order?<p>Sem ordens no âmbito autorizado.</p>:<>
|
||||
<section className="panel"><h3>Origens técnicas verificadas</h3><p>Referências aos apontamentos existentes. A validação de custos não modifica nem verifica a execução técnica.</p><ul>{order.sources.map(s=><li key={s.id}>#{s.id} v{s.version} · {s.origin} · {s.quantity} {s.unit} · {s.kind==='time'?'Trabalho efectivo':'Consumo efectivo'}</li>)}</ul></section>
|
||||
{allowed('prepare')&&source&&<form className="panel" onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);const previous=order.rates.filter(r=>r.identifier===f.get('identifier')).at(-1);send(`/api/direct-costs/orders/${order.id}/rates`,{entry_id:source.id,entry_version:source.version,identifier:f.get('identifier'),version:(previous?.version??0)+1,previous_id:previous?.id??null,currency:'MZN',unit:source.kind==='time'?'H':source.unit,amount:f.get('amount'),evidence:f.get('evidence')});}}><h3>Preparar taxa sintética versionada</h3>
|
||||
<label>Origem da taxa<select value={source.id} onChange={e=>setSourceId(e.target.value)}>{order.sources.map(s=><option key={s.id} value={s.id}>#{s.id} · {s.kind==='time'?'Tempo':'Material'} v{s.version}</option>)}</select></label>
|
||||
<p>Âmbito: esta ordem e versão da origem; MZN/{source.kind==='time'?'H':source.unit}. Zero é uma taxa explícita; campo vazio não vale zero.</p>
|
||||
<label>Identificador da taxa<input name="identifier" required minLength={3} placeholder="TEST-MATERIAL ou TEST-TRABALHO"/></label><label>Taxa de custo exacta<input name="amount" required inputMode="decimal" placeholder="50.00"/></label><label>Origem / evidência da taxa<input name="evidence" required minLength={3} placeholder="Pressuposto sintético identificado"/></label><button disabled={disabled}>Propor taxa</button></form>}
|
||||
<section className="panel"><h3>Taxas e decisões</h3>{order.rates.map(r=><article key={r.id} className="panel"><strong>#{r.id} · {r.identifier} v{r.version}</strong><p>Origem #{r.entry_id} v{r.entry_version} · {r.amount} {r.currency}/{r.unit}</p><p>{r.evidence}</p><p>{r.current?'Última versão da taxa':'Versão anterior'} · {r.decision?decisionLabel[r.decision.decision]:'Aguarda validação'}{r.decision&&` — ${r.decision.reason}`}</p>{allowed('validate')&&r.current&&!r.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('rate',r,d,reason)}/>}</article>)}</section>
|
||||
{allowed('prepare')&&<form className="panel" onSubmit={e=>{e.preventDefault();const lines=order.sources.map(s=>{const r=order.rates.find(r=>String(r.id)===String(selection[s.id]??''));return {entry_id:s.id,entry_version:s.version,rate_id:r?.id??null,rate_version:r?.version??null};});send(`/api/direct-costs/orders/${order.id}/calculations`,{currency:'MZN',version:(latest?.version??0)+1,previous_id:latest?.id??null,lines});}}><h3>Preparar cálculo com taxas explícitas</h3><p>Escolher cada versão. Nenhuma taxa é escolhida automaticamente, mesmo que seja a única ou a mais recente.</p>{order.sources.map(s=><label key={s.id}>Origem #{s.id} v{s.version}<select value={selection[s.id]??''} onChange={e=>setSelection({...selection,[s.id]:e.target.value})}><option value="">Taxa em falta — manter pendência</option>{order.rates.filter(r=>r.entry_id===s.id&&r.entry_version===s.version&&r.current&&r.decision?.decision==='approved').map(r=><option key={r.id} value={r.id}>#{r.id} · {r.identifier} v{r.version} · {r.amount} MZN/{r.unit}</option>)}</select></label>)}<button disabled={disabled}>Calcular custo directo conhecido</button></form>}
|
||||
<section className="panel"><h3>Histórico de cálculos</h3>{order.calculations.map(c=><article key={c.id} className="panel"><h3>Cálculo #{c.id} · v{c.version}</h3><p>{c.current?'Dependências actuais':'DESACTUALIZADO / HISTÓRICO — não usar como actual'} · {c.decision?decisionLabel[c.decision.decision]:'Aguarda validação'}{c.decision&&` — ${c.decision.reason}`}</p><p>{c.snapshot.label}</p><table><thead><tr><th>Origem / versão</th><th>Quantidade exacta</th><th>Taxa / versão</th><th>Custo exacto</th></tr></thead><tbody>{c.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>{l.quantity} {l.source_unit}{l.kind==='time'&&` ÷ 60 = ${l.cost_quantity_exact} H`}</td><td>{l.rate_id===null?'Em falta':`#${l.rate_id} v${l.rate_version} · ${l.rate_amount} MZN/${l.rate_unit}`}</td><td>{money(l.cost_exact)}</td></tr>)}</tbody></table><p><strong>Subtotal directo conhecido: {money(c.snapshot.known_subtotal_exact)}</strong></p><p>{c.snapshot.incomplete?'TOTAL INCOMPLETO — taxas em falta':'Perímetro directo seleccionado valorizado; custo completo não apurado.'}</p><ul>{c.snapshot.pending.map(p=><li key={p}>{p}</li>)}</ul><small>{c.snapshot.policy} · {c.snapshot.scope}</small>{allowed('validate')&&c.current&&!c.decision&&<DecisionForm disabled={disabled} onDecision={(d,reason)=>decide('calculation',c,d,reason)}/>}</article>)}</section>
|
||||
</> }</> }</section>;
|
||||
}
|
||||
function DecisionForm({disabled,onDecision}) {return <form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);onDecision(f.get('decision'),f.get('reason'));}}><label>Decisão<select name="decision"><option value="approved">Validar esta versão</option><option value="refused">Recusar esta versão</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar decisão de custos</button></form>;}
|
||||
@@ -9,6 +9,7 @@ import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
|
||||
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
|
||||
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
|
||||
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
|
||||
import DirectCostsWorkspace from './DirectCostsWorkspace.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' };
|
||||
|
||||
@@ -53,7 +54,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('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>
|
||||
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('direct_costs.view',context?.unit?.id ?? null) ? [['direct_costs', 'Custos directos de teste']] : []), ...(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}>
|
||||
@@ -64,6 +65,7 @@ function App() {
|
||||
{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 === 'direct_costs' && <DirectCostsWorkspace 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} />}
|
||||
|
||||
Reference in New Issue
Block a user