commit4
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
|
||||
export function budgetMoney(value) {
|
||||
if (value === null || value === undefined) return 'Indisponível';
|
||||
const n=BigInt(String(value)), sign=n<0n?'-':'', digits=(n<0n?-n:n).toString().padStart(3,'0');
|
||||
return `${sign}${digits.slice(0,-2).replace(/\B(?=(\d{3})+(?!\d))/g,' ')}.${digits.slice(-2)} MZN`;
|
||||
}
|
||||
const kinds={reserve:'Reserva',award:'Ajuste de adjudicação',commit:'Compromisso',release:'Libertação'};
|
||||
|
||||
export default function PurchasingBudgetWorkspace({context,can,api,busy}) {
|
||||
const [data,setData]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[working,setWorking]=useState(false),[selection,setSelection]=useState('');
|
||||
const retry=useRef(null);
|
||||
const activeContext=useRef(context.token);activeContext.current=context.token;
|
||||
const allowed=r=>can(`purchasing_budget.${r}`,context.unit?.id??null);
|
||||
const load=async()=>{const token=context.token;const next=await api('/api/purchasing-budget','GET',undefined,token);if(activeContext.current===token)setData(next);};
|
||||
useEffect(()=>{setData(null);setSelection('');setError('');setNotice('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
|
||||
const disabled=busy||working;
|
||||
const budget=data?.budgets.find(b=>String(b.id)===selection)??data?.budgets[0];
|
||||
const availableOrigins=data?.origins.filter(o=>o.cost_center_id===budget?.cost_center_id)??[];
|
||||
const canMove=!!budget?.approved_by&&Number(budget.year)===data?.current_year&&availableOrigins.length>0;
|
||||
async function send(action,input) {
|
||||
const signature=JSON.stringify([action,input,context.token]);
|
||||
if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};
|
||||
setWorking(true);setError('');setNotice('');
|
||||
try {const result=await api(`/api/purchasing-budget/${action}`,'POST',input,context.token,retry.current.key);if(activeContext.current!==context.token)return;retry.current=null;await load();if(action==='create')setSelection(String(result.id));setNotice(result.replayed?'Resposta recuperada; nenhum efeito duplicado.':'Operação sintética registada. Saldos actualizados pelo núcleo comum.');}
|
||||
catch(e){setError(e.message);}finally{setWorking(false);}
|
||||
}
|
||||
const submit=(action,convert)=>event=>{event.preventDefault();send(action,convert(Object.fromEntries(new FormData(event.currentTarget))));};
|
||||
const originOptions=availableOrigins.map(o=>{
|
||||
const movements=data.entries.filter(e=>e.requisition_id===o.id).map(e=>e.kind);
|
||||
const stage=movements.includes('release')?'Libertada':movements.includes('commit')?'Comprometida':movements.includes('award')?'Adjudicação ajustada':movements.includes('reserve')?'Reservada':'Sem movimentos';
|
||||
return <option key={o.id} value={o.id}>{o.reference} · {stage} · centro #{o.cost_center_id}</option>;
|
||||
});
|
||||
const moneyInput=(name,label,value='')=><label>{label}<input name={name} inputMode="decimal" pattern="[0-9]{1,12}([.][0-9]{1,2})?" defaultValue={value} required placeholder="0.00" /></label>;
|
||||
return <section><p className="eyebrow">DEMONSTRAÇÃO ORÇAMENTAL</p><h1>Compras — controlo orçamental</h1>
|
||||
<p className="alert">ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Dotações, reservas, compromissos e consumo usam o núcleo comum de Compras. Este percurso não é o orçamento industrial.</p>
|
||||
<p>Empresa: <strong>{context.company.name}</strong> · Unidade: <strong>{context.unit?.name}</strong> · Moeda: MZN</p>
|
||||
<p className="help">Prepare a dotação, entre como validador para a aprovar e use a conta de operação para os movimentos. Decisões exigem identidade distinta. Consumo aqui é apenas um efeito orçamental simulado, sem factura, obrigação ou pagamento.</p>
|
||||
{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="notice" role="status">{notice}</p>}
|
||||
<button disabled={disabled} onClick={()=>load().catch(e=>setError(e.message))}>Actualizar saldos e histórico</button>
|
||||
{!data?<p role="status">A carregar o percurso…</p>:<>
|
||||
{allowed('create')&&<section className="panel"><h2>Preparar dotação</h2><form onSubmit={submit('create',d=>({...d,cost_center_id:Number(d.cost_center_id),year:Number(d.year),includes_tax:d.includes_tax==='true'}))}>
|
||||
<fieldset disabled={disabled}><label>Centro de custo<select name="cost_center_id" required>{data.centres.map(c=><option key={c.id} value={c.id}>{c.code}</option>)}</select></label>
|
||||
<label>Exercício<input name="year" type="number" min="2026" max="2100" defaultValue={data.current_year} required /></label>
|
||||
<label>Natureza<select name="kind"><option value="operating">Corrente</option><option value="investment">Investimento</option></select></label>
|
||||
<label>Base da dotação<select name="includes_tax"><option value="true">Com imposto indicado</option><option value="false">Sem imposto — usar subtotal</option></select></label>
|
||||
{moneyInput('amount','Dotação inicial (MZN)','1000.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Dotação exclusivamente sintética para a equipa." /></label><button>Guardar proposta de dotação</button></fieldset>
|
||||
</form></section>}
|
||||
<section className="panel"><h2>Dotações e saldos</h2>{!budget?<p>Não há dotações neste contexto. A conta de preparação pode criar a primeira.</p>:<>
|
||||
<label>Dotação<select value={String(budget.id)} onChange={e=>setSelection(e.target.value)}>{data.budgets.map(b=><option key={b.id} value={b.id}>#{b.id} · centro #{b.cost_center_id} · {b.year} · {b.includes_tax?'com imposto':'sem imposto'}</option>)}</select></label>
|
||||
<p>Estado: <strong>{budget.approved_by?'Aprovada':'Pendente de aprovação'}</strong> · Preparada por {data.actors[budget.created_by]??`#${budget.created_by}`} {budget.approved_by&&`· Validada por ${data.actors[budget.approved_by]??`#${budget.approved_by}`}`}</p>
|
||||
<dl className="home-context">{[['Dotação inicial',budget.amount_minor],['Reforço aprovado',budget.balance.reinforced_minor],['Total aprovado',budget.balance.approved_total_minor],['Reservado',budget.balance.reserved_minor],['Comprometido',budget.balance.committed_minor],['Consumido',budget.balance.consumed_minor],['Disponível',budget.balance.available_minor]].map(([label,value])=><div key={label}><dt>{label}</dt><dd>{budgetMoney(value)}</dd></div>)}</dl>
|
||||
{allowed('approve')&&!budget.approved_by&&<button disabled={disabled} onClick={()=>send('approve',{budget_id:budget.id})}>Aprovar dotação por identidade distinta</button>}
|
||||
</>}</section>
|
||||
{(allowed('record')||allowed('consume'))&&!canMove&&<p role="status">Para operar, seleccione uma dotação aprovada do exercício actual com origem sintética no mesmo centro. O guião usa o centro ORC-DEMO.</p>}
|
||||
{allowed('record')&&canMove&&<section className="panel"><h2>Movimentos da origem sintética</h2><p>Reserva → ajuste de adjudicação → compromisso. A libertação só está disponível antes do compromisso. Uma repetição não volta a movimentar o saldo.</p>
|
||||
<form key={`movement-${budget.id}`} onSubmit={submit('movement',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Movimento<select name="kind">{Object.entries(kinds).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label>{moneyInput('total','Total indicado (MZN)','300.00')}{moneyInput('subtotal','Subtotal sem imposto (MZN)','300.00')}<button>Registar movimento sintético</button></fieldset></form>
|
||||
</section>}
|
||||
{allowed('consume')&&canMove&&<section className="panel"><h2>Consumo orçamental simulado</h2><p>Requer compromisso anterior. A referência identifica uma simulação; não cria uma factura ou obrigação.</p><form key={`consume-${budget.id}`} onSubmit={submit('consume',d=>({...d,origin_id:Number(d.origin_id)}))}><fieldset disabled={disabled}><label>Origem<select name="origin_id">{originOptions}</select></label><label>Referência sintética<input name="reference" defaultValue="SIM-CONSUMO-001" required minLength={3} maxLength={100} /></label>{moneyInput('total','Total simulado (MZN)','100.00')}{moneyInput('subtotal','Subtotal simulado (MZN)','100.00')}<button>Registar consumo simulado</button></fieldset></form></section>}
|
||||
{allowed('reinforcement.propose')&&budget?.approved_by&&<section className="panel"><h2>Propor reforço da dotação #{budget.id}</h2><form onSubmit={submit('reinforcement',d=>({...d,budget_id:budget.id}))}><fieldset disabled={disabled}><label>Referência<input name="reference" defaultValue="SIM-REFORCO-001" required maxLength={100} /></label>{moneyInput('amount','Montante do reforço (MZN)','200.00')}<label>Fundamento<textarea name="reason" required minLength={3} defaultValue="Reforço sintético para ensaio da equipa." /></label><button>Propor reforço</button></fieldset></form></section>}
|
||||
<section className="panel"><h2>Reforços e decisões</h2>{data.reinforcements.length===0?<p>Sem propostas de reforço.</p>:data.reinforcements.map(r=><article key={r.id}><h3>{r.reference} · {budgetMoney(r.amount_minor)}</h3><p>Dotação #{r.budget_id} · {r.status} · Proponente: {data.actors[r.created_by]??r.created_by}</p><p>{r.reason}</p>{r.review_reason&&<p>Decisão: {r.review_reason}</p>}{allowed('reinforcement.review')&&r.status==='pending'&&<form onSubmit={submit('review',d=>({...d,reinforcement_id:r.id}))}><fieldset disabled={disabled}><label>Decisão<select name="decision"><option value="approved">Aprovar</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minLength={3} /></label><button>Registar decisão distinta</button></fieldset></form>}</article>)}</section>
|
||||
<section className="panel"><h2>Movimentos rastreáveis</h2><div className="table-scroll"><table><thead><tr><th>Movimento</th><th>Dotação / origem</th><th>Actor</th><th>Reserva Δ</th><th>Compromisso Δ</th><th>Data</th></tr></thead><tbody>{data.entries.map(e=><tr key={e.id}><td>#{e.id} · {kinds[e.kind]??e.kind}</td><td>#{e.budget_id} / {data.origins.find(o=>o.id===e.requisition_id)?.reference??e.requisition_id}</td><td>{data.actors[e.created_by]??e.created_by}</td><td>{budgetMoney(e.reserved_minor)}</td><td>{budgetMoney(e.committed_minor)}</td><td>{e.created_at}</td></tr>)}</tbody></table></div>{!data.entries.length&&<p>Ainda não há movimentos.</p>}</section>
|
||||
<section className="panel"><h2>Consumos e respectivas referências</h2>{data.consumption_sources.map(s=><p key={s.id}>{s.reference} · origem #{s.origin_id} · total {budgetMoney(s.total_minor)} · efeito #{data.consumptions.find(c=>c.invoice_id===s.id)?.id??'pendente'}</p>)}</section>
|
||||
<section className="panel"><h2>Histórico de decisões e efeitos</h2>{data.history.map(h=><details key={h.id}><summary>#{h.id} · {h.action} · {data.actors[h.actor_id]??h.actor_id} · {h.created_at}</summary><pre>{JSON.stringify(typeof h.payload==='string'?JSON.parse(h.payload):h.payload,null,2)}</pre></details>)}</section>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -86,13 +86,13 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
|
||||
{area === 'service_drafts' && form.lines.length < 100 && <button type="button" onClick={() => setForm({ ...form, lines: [...form.lines, newLine()] })}>Adicionar linha</button>}
|
||||
</>}
|
||||
<div className="actions a-form-actions"><button className="primary" disabled={busy}>Guardar {isDraft(area) ? 'rascunho' : 'cadastro'}</button><button type="button" onClick={() => setForm(null)}>Cancelar edição</button></div>
|
||||
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em rascunho' : ''}</p>
|
||||
{selected.data.description && <p>{selected.data.description}</p>}
|
||||
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em rascunho' : ''}</p>
|
||||
{selected.workshop_commercial_state && <p className="alert home-notice">{selected.workshop_commercial_state === "superseded" ? "HISTÓRICO — preparação comercial substituída. Sem nova confirmação." : selected.workshop_commercial_state === "cancelled" ? "PREPARAÇÃO COMERCIAL CANCELADA — apenas consulta histórica." : "Origem de oficina protegida. Correcção apenas pelo circuito comercial próprio."}</p>}{selected.data.description && <p>{selected.data.description}</p>}
|
||||
{isDraft(area) && <><p><strong>Cliente:</strong> {selected.data.client_name}</p>{selected.source_id && <p>Origem: prestação #{selected.source_id}, versão {selected.data.source_version}</p>}<p><strong>Moeda:</strong> {selected.data.currency || 'Não indicada — pendente'}</p><div className="table-scroll"><table><thead><tr><th>Serviço</th><th>Quantidade</th><th>Preço proposto</th></tr></thead><tbody>{selected.data.lines.map((line, i) => <tr key={i}><td>{line.service_name}</td><td>{line.quantity ?? 'Por preencher'}</td><td>{line.unit_price ?? 'Por preencher'}</td></tr>)}</tbody></table></div><p><strong>Base aritmética:</strong> {selected.base_amount ?? 'Incompleta'} {selected.data.currency || '(moeda pendente)'}</p><p className="help">Esta base não é um total fiscal ou valor definitivo a pagar. Imposto por validar; contabilidade não processada.</p>
|
||||
{Object.keys(selected.pending).length > 0 && <div className="a-pending"><h3>Pendências</h3><ul>{Object.entries(selected.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul></div>}
|
||||
{area === 'service_drafts' && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
|
||||
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
|
||||
{area === 'billing_drafts' && !selected.confirmation && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
|
||||
{area === 'service_drafts' && !selected.workshop_commercial_state && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
|
||||
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
|
||||
{area === 'billing_drafts' && !selected.confirmation && (!selected.workshop_commercial_state || selected.workshop_commercial_state === 'current') && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
|
||||
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
|
||||
{area === 'billing_drafts' && (selected.confirmation ? <button onClick={() => setPreviewMode('history')}>Abrir confirmação interna histórica</button> : can('issuer_documents.view') && can('client_documents.view') && can('draft_documents.view',context.unit?.id) && <button className="primary" onClick={() => setPreviewMode('draft')}>Pré-visualizar rascunho</button>)}
|
||||
</>}
|
||||
|
||||
@@ -10,5 +10,5 @@ export default function WorkshopBillingWorkspace({context,can,api,busy}){
|
||||
return <section><p className="eyebrow">WORKSHOP-BILLING-DRAFT-SIM-v1</p><h1>Oficina → rascunho de facturação</h1><div className="alert home-notice">ENSAIO — SEM UTILIZAÇÃO OPERACIONAL. Preços comerciais explícitos; custo interno não determina preço de venda. Sem emissão ou confirmação automática.</div><div ref={feedback} aria-live="polite">{notice&&<p className="alert success">{notice}</p>}{error&&<p role="alert" className="alert error">{error}</p>}</div>{!data?<p>A carregar…</p>:data.blocked?<p>Conversão real bloqueada nesta instância.</p>:<><button onClick={()=>load().catch(e=>setError(e.message))} disabled={disabled}>Actualizar versões explicitamente</button><label>Ordem<select value={order?.id??''} onChange={e=>setOrderId(e.target.value)}>{data.orders.map(o=><option key={o.id} value={o.id}>{o.reference} · #{o.id}</option>)}</select></label>{order?<><p>Cliente #{order.client_id} · MZN explícito · Seleccionar origens integrais verificadas.</p>
|
||||
{allowed('prepare')&&!order.generation&&<form className="panel" noValidate onSubmit={e=>{e.preventDefault();try{const input=workshopBillingProposal(order,data.services,new FormData(e.currentTarget));send("proposals",input);}catch(error){setError(error.message);}}}><h2>Seleccionar e preparar proposta comercial</h2>{order.entries.map(r=><fieldset key={r.id}><legend>Origem #{r.id} v{r.version} · {r.kind==='time'?`${r.minutes} MIN verificados`:`${r.quantity} ${r.data.unit} consumidos — ${r.data.resource}`}</legend><label><input type="checkbox" name={`select-${r.id}`} style={{display:'inline',width:'auto'}}/>Incluir origem integral</label><label>Serviço comercial<select name={`service-${r.id}`}><option value="">Seleccionar serviço e versão</option>{data.services.map(s=><option key={s.id} value={s.id}>{s.name} · #{s.id} v{s.version}</option>)}</select></label><label>Preço comercial MZN/{r.kind==='time'?'H':r.data.unit}<input name={`price-${r.id}`} inputMode="decimal"/></label><label>Fonte comercial e versão (preenchimento obrigatório)<input name={`source-${r.id}`} placeholder="Escreva a referência da tabela"/></label></fieldset>)}<label>Fundamento da proposta / exclusões<input name="evidence" required minLength={3}/></label><button disabled={disabled}>Submeter proposta comercial</button></form>}
|
||||
{order.proposals.map(p=><article className="panel" key={p.id}><h2>Proposta #{p.id} · v{p.version}</h2><p>{p.current?'Versão actual':'HISTÓRICO / DESACTUALIZADA'} · {p.decision?`${p.decision.decision==='approved'?'Revista e aprovada':'Recusada'} — ${p.decision.reason}`:'Aguarda revisão distinta'}</p><p>{p.snapshot.evidence}</p><table><thead><tr><th>Origem exacta</th><th>Serviço / versão</th><th>Quantidade comercial</th><th>Preço MZN</th><th>Fonte</th></tr></thead><tbody>{p.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>#{l.service_id} v{l.service_version}</td><td>{l.quantity} {l.unit}</td><td>{l.unit_price}</td><td>{l.price_source}</td></tr>)}</tbody></table><p>Soma sem imposto: <strong>{p.snapshot.base_exact} MZN</strong> · Fiscalidade pendente.</p>{allowed('review')&&p.current&&!p.decision&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('reviews',{id:p.id,version:p.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Revisão comercial<select name="decision"><option value="approved">Aprovar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar revisão comercial</button></form>}{allowed('generate')&&p.current&&p.decision?.decision==='approved'&&!order.generation&&<button disabled={disabled} onClick={()=>send('generations',{id:p.id,version:p.version})}>Gerar prestação e rascunho rastreáveis</button>}</article>)}
|
||||
{order.generation&&<section className="panel"><h2>Rascunho gerado — sem validade fiscal</h2><p>Prestação #{order.generation.service_draft_id} · Rascunho de facturação #{order.generation.billing_draft_id} · Proposta #{order.generation.proposal_id}</p><p>Origens utilizadas uma única vez. Edição directa bloqueada; correcção exige circuito próprio, fora deste recorte.</p></section>}</>:<p>Sem ordens autorizadas neste contexto.</p>}</>}</section>;
|
||||
{order.generation&&<section className="panel"><h2>Rascunho original gerado — sem validade fiscal</h2>{order.generation.commercial_state === "superseded" && <p className="alert home-notice">Original substituído; consultar a cadeia em Correcção e cancelamento.</p>}{order.generation.latest_commercial_change?.kind === "cancel" && <p className="alert home-notice">Preparação comercial cancelada. Origens não libertadas.</p>}{order.generation.latest_commercial_change?.new_billing_id && <p>Destino sucessor: prestação #{order.generation.latest_commercial_change.new_service_id} · rascunho #{order.generation.latest_commercial_change.new_billing_id}</p>}<p>Prestação #{order.generation.service_draft_id} · Rascunho de facturação #{order.generation.billing_draft_id} · Proposta #{order.generation.proposal_id}</p><p>Origens utilizadas uma única vez. Edição directa bloqueada; correcção exige circuito próprio, fora deste recorte.</p></section>}</>:<p>Sem ordens autorizadas neste contexto.</p>}</>}</section>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
export default function WorkshopCorrectionsWorkspace({context,can,api,busy}){
|
||||
const [data,setData]=useState(null),[selected,setSelected]=useState(''),[kind,setKind]=useState('replace'),[error,setError]=useState(''),[notice,setNotice]=useState(''),[working,setWorking]=useState(false);const retry=useRef(null);
|
||||
const allowed=r=>can(`workshop_corrections.${r}`,context.unit?.id??null),disabled=busy||working;
|
||||
const load=async()=>setData(await api('/api/workshop-corrections'));useEffect(()=>{setData(null);setSelected('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
|
||||
const g=data?.generations.find(g=>String(g.id)===selected)??data?.generations[0];
|
||||
async function send(action,input){setWorking(true);setError('');setNotice('');const path=`/api/workshop-corrections/${g.id}/${action}`,signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};try{const r=await api(path,'POST',input,context.token,retry.current.key);retry.current=null;setNotice(`Registado #${r.id} · versão ${r.version}.`);await load();}catch(e){setError(e.message);}finally{setWorking(false);}}
|
||||
function request(e){e.preventDefault();const f=new FormData(e.currentTarget),last=g.requests.at(-1),base=g.current;const lines=kind==='replace'?base.original_origins.map((o,i)=>{const s=data.services.find(s=>String(s.id)===f.get(`service-${i}`));return {entry_id:o.entry_id,service_id:s?.id,service_version:s?.version,price:f.get(`price-${i}`),price_source:f.get(`source-${i}`),description:f.get(`description-${i}`)};}):[];send('requests',{version:(last?.version??0)+1,previous_id:last?.id??null,kind,billing_id:base.billing_id,billing_version:base.billing_version,service_id:base.service_id,service_version:base.service_version,lines,reason:f.get('reason')});}
|
||||
return <section><p className="eyebrow">WORKSHOP-BILLING-CORRECTION-SIM-v1</p><h1>Correcção e cancelamento de oficina</h1><div className="alert home-notice">ENSAIO — SEM UTILIZAÇÃO REAL. Originais preservados; cancelamento não liberta origens para nova cobrança.</div>{error&&<p className="alert error" role="alert">{error}</p>}{notice&&<p className="alert success" role="status">{notice}</p>}{!data?<p>A carregar…</p>:data.blocked?<p>Operações reais bloqueadas.</p>:<><button onClick={()=>load().catch(e=>setError(e.message))} disabled={disabled}>Actualizar versões explicitamente</button><label>Geração de oficina<select value={g?.id??''} onChange={e=>setSelected(e.target.value)}>{data.generations.map(g=><option key={g.id} value={g.id}>Ordem #{g.order_id} · geração #{g.id}</option>)}</select></label>{!g?<p>Sem gerações autorizadas.</p>:<>{g.issue&&<p className="alert home-notice">{g.issue}</p>}{g.current&&<p>Destino actual: prestação #{g.current.service_id} v{g.current.service_version} · rascunho #{g.current.billing_id} v{g.current.billing_version} · MZN</p>}
|
||||
{allowed('request')&&g.current&&<form className="panel" key={`${g.id}-${g.current.billing_id}-${kind}`} onSubmit={request}><h2>Preparar pedido versionado</h2><label>Operação<select value={kind} onChange={e=>setKind(e.target.value)}><option value="replace">Substituir por novos rascunhos</option><option value="cancel">Cancelar preparação comercial</option><option value="withdraw">Desistir do último pedido não aplicado</option></select></label>{kind==='replace'&&g.current.original_origins.map((o,i)=><fieldset key={o.entry_id}><legend>Origem #{o.entry_id} v{o.entry_version} · {o.quantity} {o.unit} integrais</legend><label>Serviço comercial<select name={`service-${i}`} required defaultValue={g.current.current_lines[i].service_id}>{data.services.map(s=><option key={s.id} value={s.id}>{s.name} · #{s.id} v{s.version}</option>)}</select></label><label>Preço comercial MZN/{o.unit}<input name={`price-${i}`} required defaultValue={g.current.current_lines[i].unit_price}/></label><label>Fonte e versão comercial<input name={`source-${i}`} required minLength={3} defaultValue={o.price_source}/></label><label>Descrição comercial<input name={`description-${i}`} required minLength={3} defaultValue={g.current.current_lines[i].description}/></label></fieldset>)}<label>Motivo<input name="reason" required minLength={3}/></label><button disabled={disabled}>Submeter pedido</button></form>}
|
||||
{g.requests.map(r=><article className="panel" key={r.id}><h2>Pedido #{r.id} · v{r.version} · {r.kind==='replace'?'Substituição':r.kind==='cancel'?'Cancelamento':'Desistência'}</h2><p>{r.applied?'Aplicado':r.current?'Referências actuais':'Histórico / desactualizado'} · {r.review?`${r.review.decision==='approved'?'Aprovado':'Recusado'} — ${r.review.reason}`:'Sem decisão'}</p><p>{r.snapshot.reason}</p><p>Destino referido: prestação #{r.snapshot.baseline.service_id} · rascunho #{r.snapshot.baseline.billing_id}</p>{r.kind==='replace'&&<><table><thead><tr><th>Origem</th><th>Quantidade</th><th>Preço MZN</th><th>Fonte</th></tr></thead><tbody>{r.snapshot.lines.map(l=><tr key={l.entry_id}><td>#{l.entry_id} v{l.entry_version}</td><td>{l.quantity} {l.unit}</td><td>{l.unit_price}</td><td>{l.price_source}</td></tr>)}</tbody></table><p>Anterior: {r.snapshot.baseline.current_base_exact} MZN sem imposto. Nova soma: <strong>{r.snapshot.base_exact} MZN</strong>. Não é total fiscal.</p></>}{allowed('review')&&r.current&&!r.review&&!r.applied&&r.kind!=='withdraw'&&<form onSubmit={e=>{e.preventDefault();const f=new FormData(e.currentTarget);send('reviews',{id:r.id,version:r.version,decision:f.get('decision'),reason:f.get('reason')});}}><label>Decisão<select name="decision"><option value="approved">Aprovar versão exacta</option><option value="refused">Recusar versão exacta</option></select></label><label>Fundamento<input name="reason" required minLength={3}/></label><button disabled={disabled}>Registar revisão distinta</button></form>}{allowed('apply')&&r.current&&!r.applied&&r.review?.decision==='approved'&&<button disabled={disabled} onClick={()=>send('applications',{id:r.id,version:r.version})}>Aplicar {r.kind==='replace'?'substituição':'cancelamento'} revisto</button>}</article>)}
|
||||
<h2>Histórico de destinos</h2><p>Original preservado: prestação #{g.service_draft_id} · rascunho #{g.billing_draft_id}</p>{g.applications.map(a=><div className="panel" key={a.id}><p>{a.kind==='replace'?'Substituído':'Preparação cancelada'}: prestação #{a.old_service_id} · rascunho #{a.old_billing_id}</p>{a.new_billing_id&&<p>Sucessores: prestação #{a.new_service_id} · rascunho #{a.new_billing_id}</p>}<p>Origens canónicas preservadas; sem nova captura técnica.</p></div>)}</>}</>}</section>;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
|
||||
const available=groups.flatMap(g=>g.items);
|
||||
const links=(rules)=>rules.filter(([,permission,scope])=>can(permission,scope)).map(([id,,,label])=>({id,label:label??available.find(i=>i.id===id)?.label})).filter(i=>available.some(a=>a.id===i.id));
|
||||
const prepare=links([
|
||||
['purchasing_budget','purchasing_budget.create',unit],['purchasing_budget','purchasing_budget.record',unit],
|
||||
['workshop_corrections','workshop_corrections.request',unit],
|
||||
['workshop_billing','workshop_billing.prepare',unit],
|
||||
['clients','clients.create',null],['services','services.create',null],['service_drafts','service_drafts.create',unit],['billing_drafts','billing_drafts.create',unit],
|
||||
['documents','issuer_documents.edit',null],['documents','client_documents.edit',null],['documents','draft_documents.edit',unit],
|
||||
@@ -12,6 +14,8 @@ export default function WorkspaceHome({context,can,groups,navigate}) {
|
||||
['workshop','workshop.open',unit],['direct_costs','direct_costs.prepare',unit],['industrial_costs','industrial_costs.prepare',unit],['industrial_budgets','industrial_budgets.prepare',unit],
|
||||
]);
|
||||
const validate=links([
|
||||
['purchasing_budget','purchasing_budget.approve',unit],
|
||||
['workshop_corrections','workshop_corrections.review',unit],
|
||||
['workshop_billing','workshop_billing.review',unit],
|
||||
['catalogue_accounting','catalogue_accounting.approve',null],['service_fiscal','service_fiscal.approve',null],['service_invoices','invoice_series.approve',null],
|
||||
['service_receivables','service_receivables.confirm',unit],['service_receivables','service_receivables.correction_approve',unit],['workshop','workshop.verify',unit],['direct_costs','direct_costs.validate',unit],['industrial_costs','industrial_costs.validate',unit],['industrial_budgets','industrial_budgets.validate',unit],
|
||||
|
||||
@@ -4,10 +4,11 @@ export function navigationGroups(can, unit) {
|
||||
const item=(id,label,permission,scope=null)=>({id,label,visible:can(permission,scope)});
|
||||
return [
|
||||
{id:'start',label:'Início',items:[{id:'home',label:'Página inicial',visible:true}]},
|
||||
{id:'purchasing',label:'Compras',items:[item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]},
|
||||
{id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]},
|
||||
{id:'billing',label:'Facturação',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit),item('service_invoices','Documentos e séries de teste','service_invoices.view',unit),item('preview_model','Apresentação do rascunho','issuer_documents.view')]},
|
||||
{id:'receivables',label:'Recebimentos',items:[item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit)]},
|
||||
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)]},
|
||||
{id:'workshop',label:'Oficinas',items:[item('workshop','Ordens, tarefas e apontamentos de teste','workshop.view',unit),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento','workshop_corrections.view',unit)]},
|
||||
{id:'costs',label:'Custos e orçamento',items:[item('direct_costs','Custo directo de teste','direct_costs.view',unit),item('industrial_costs','Lotes e custos industriais de teste','industrial_costs.view',unit),item('industrial_budgets','Orçamentos e comparação','industrial_budgets.view',unit)]},
|
||||
{id:'accounting',label:'Contabilidade e fiscalidade',items:[item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
|
||||
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('access','Acessos','access.manage')]},
|
||||
|
||||
@@ -12,10 +12,12 @@ import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
|
||||
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
|
||||
import DirectCostsWorkspace from './DirectCostsWorkspace.jsx';
|
||||
import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
|
||||
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
|
||||
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
|
||||
import WorkspaceNavigation, {navigationGroups} from './WorkspaceNavigation.jsx';
|
||||
import WorkspaceHome from './WorkspaceHome.jsx';
|
||||
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
|
||||
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.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' };
|
||||
|
||||
@@ -70,6 +72,7 @@ function App() {
|
||||
{!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}>
|
||||
{page === 'home' && <WorkspaceHome context={context} can={can} groups={groups} navigate={navigate} />}
|
||||
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'workshop_corrections' && <WorkshopCorrectionsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
|
||||
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
|
||||
{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); }} />}
|
||||
@@ -78,6 +81,8 @@ function App() {
|
||||
{page === 'workshop' && <WorkshopWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'direct_costs' && <DirectCostsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'industrial_budgets' && <IndustrialBudgetsWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'purchasing_budget' && <PurchasingBudgetWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'supplier_payables' && <section className="panel"><h1>Obrigações a fornecedores</h1><p>Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.</p><a href="/supplier-payables">Abrir preparação e consulta de obrigações</a></section>}
|
||||
{page === 'industrial_costs' && <IndustrialCostsWorkspace 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} />}
|
||||
|
||||
Reference in New Issue
Block a user