commit melhorias sgf
This commit is contained in:
@@ -57,4 +57,16 @@ th{background:#f8fafc;color:#475569}th,td{border-color:var(--tempo-line);line-he
|
||||
.module-menu summary{cursor:pointer;padding:.5rem}.module-menu details>div{position:absolute;top:100%;left:0;background:#fff;box-shadow:0 6px 24px #1232;min-width:240px;max-height:65vh;overflow:auto;padding:.5rem}
|
||||
.module-menu details button{display:block;width:100%;text-align:left;margin:.2rem 0}.module-menu [aria-current=page]{background:#e1efed;font-weight:700}
|
||||
.context-transition-backdrop{position:fixed;inset:0;background:#10233299;z-index:100;display:grid;place-items:center;padding:1rem}.context-transition-dialog{max-width:580px;max-height:90vh;overflow:auto}
|
||||
.module-dropdowns{gap:.35rem;justify-content:flex-start;border-top:1px solid var(--tempo-line)}
|
||||
.module-dropdowns>details>summary{list-style:none;display:flex;align-items:center;gap:1.4rem;padding:.75rem 1.1rem;border-radius:6px;font-weight:600;color:#334155}
|
||||
.module-dropdowns>details>summary::-webkit-details-marker{display:none}.module-dropdowns>details>summary:hover,.module-dropdowns>details[open]>summary{background:#e2e8ef}
|
||||
.module-dropdowns>details.active-module>summary{background:#dcece7;color:#175346;box-shadow:inset 0 -3px #28715d}
|
||||
.module-dropdowns>details>div{z-index:40;min-width:280px;max-width:min(380px,90vw);border:1px solid #d9e1e8;border-radius:8px;padding:.6rem;box-shadow:0 12px 30px #10233226}
|
||||
.module-dropdowns .module-dropdown-title{margin:.2rem .5rem .7rem;font-weight:700;color:#175346}.module-dropdowns section{margin:0;padding:.35rem 0;border:0;border-top:1px solid #edf1f4;background:transparent;box-shadow:none}
|
||||
.module-dropdowns section h2{margin:.4rem .5rem;font-size:.75rem;letter-spacing:.04em;text-transform:uppercase;color:#64748b}.module-dropdowns section button{border:0;background:transparent;color:#253c4b;padding:.65rem .7rem;border-radius:5px}
|
||||
.module-dropdowns section button:hover,.module-dropdowns section button:focus-visible{background:#edf5f1}.module-dropdowns section button[aria-current=page]{background:#dcece7;color:#175346}
|
||||
@media(max-width:700px){.module-dropdowns{gap:.15rem}.module-dropdowns>details>summary{padding:.65rem .7rem;gap:.7rem}.module-dropdowns>details>div{left:.6rem;right:.6rem;width:auto;max-width:none;max-height:60vh}.module-topbar .topbar-account{flex-wrap:wrap;gap:.7rem}}
|
||||
@media(max-width:700px){.module-topbar,.module-menu{padding:.6rem}.module-topbar nav{width:100%}.module-menu details>div{position:fixed;left:1rem;right:1rem;top:auto;min-width:0}.module-topbar .topbar-account{display:flex}}
|
||||
|
||||
/* Authorized module overview: no business totals inferred from page rows. */
|
||||
.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.overview-grid article{border:1px solid #d8e2df;border-radius:10px;padding:18px;background:#fff}.overview-grid article button{display:block;text-align:left;margin:8px 0;max-width:100%}.overview-steps{display:flex;flex-wrap:wrap;gap:12px 30px;padding:18px 30px;background:#edf4f1;border-radius:8px}.overview-steps li{padding:6px 10px}.overview-definitions{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px}.overview-definitions dt{font-weight:650;margin-bottom:8px}.overview-definitions dd{margin:0;line-height:1.6}.workspace-overview td small{display:block;margin-top:6px}.workspace-overview .table-scroll{overflow-x:auto}.workspace-overview .actions{flex-wrap:wrap}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
date,reference,debit,credit,balance
|
||||
2026-11-10,QA-OUT-20,20.00,0.00,-20.00
|
||||
2026-11-10,QA-IN-20,0.00,20.00,0.00
|
||||
|
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"contract": "SGF-FISCAL-RULES-COMMON-QA-v1",
|
||||
"not_tax_law": true,
|
||||
"cases": [
|
||||
{
|
||||
"fixture_id": "QA1",
|
||||
"definition": {
|
||||
"family": "qa",
|
||||
"operation": "percentage_add",
|
||||
"currency": "MZN",
|
||||
"unit": "money",
|
||||
"precision": 2,
|
||||
"rounding": "DOWN",
|
||||
"priority": 10,
|
||||
"source_reference": "QA-FISCAL-COMMON-20261110",
|
||||
"source_status": "qa_checked",
|
||||
"conditions": "Somente ensaio aritmético artificial; não enquadra contribuintes ou documentos.",
|
||||
"expression": {
|
||||
"language": "investmoz.tax.v1",
|
||||
"nodes": {
|
||||
"base": {
|
||||
"op": "input",
|
||||
"key": "base"
|
||||
},
|
||||
"rate": {
|
||||
"op": "parameter",
|
||||
"key": "rate"
|
||||
},
|
||||
"raw": {
|
||||
"op": "multiply",
|
||||
"args": [
|
||||
"base",
|
||||
"rate"
|
||||
]
|
||||
}
|
||||
},
|
||||
"result": "raw"
|
||||
},
|
||||
"label": "Regra artificial até Novembro",
|
||||
"parameters": {
|
||||
"rate": "0.16"
|
||||
},
|
||||
"valid_from": "2026-01-01",
|
||||
"valid_until": "2026-11-30"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fixture_id": "QA2",
|
||||
"definition": {
|
||||
"family": "qa",
|
||||
"operation": "percentage_add",
|
||||
"currency": "MZN",
|
||||
"unit": "money",
|
||||
"precision": 2,
|
||||
"rounding": "DOWN",
|
||||
"priority": 10,
|
||||
"source_reference": "QA-FISCAL-COMMON-20261110",
|
||||
"source_status": "qa_checked",
|
||||
"conditions": "Somente ensaio aritmético artificial; não enquadra contribuintes ou documentos.",
|
||||
"expression": {
|
||||
"language": "investmoz.tax.v1",
|
||||
"nodes": {
|
||||
"base": {
|
||||
"op": "input",
|
||||
"key": "base"
|
||||
},
|
||||
"rate": {
|
||||
"op": "parameter",
|
||||
"key": "rate"
|
||||
},
|
||||
"raw": {
|
||||
"op": "multiply",
|
||||
"args": [
|
||||
"base",
|
||||
"rate"
|
||||
]
|
||||
}
|
||||
},
|
||||
"result": "raw"
|
||||
},
|
||||
"label": "Regra artificial desde Dezembro",
|
||||
"parameters": {
|
||||
"rate": "0.17"
|
||||
},
|
||||
"valid_from": "2026-12-01",
|
||||
"valid_until": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {accountingSections,visibleAccountingSections,accountingFunctions,accountingReports,filterAccountingReports} from './accounting-menu.js';
|
||||
|
||||
export default function AccountingMenuWorkspace({section,context,can,navigate}) {
|
||||
const ComposedAccountingWorkspace=React.lazy(()=>import('./ComposedAccountingWorkspace.jsx'));
|
||||
|
||||
export default function AccountingMenuWorkspace({section,context,can,navigate,api}) {
|
||||
const [search,setSearch]=useState(''),[family,setFamily]=useState(''),[selected,setSelected]=useState(null);
|
||||
useEffect(()=>{setSearch('');setFamily('');setSelected(null);},[context.token]);
|
||||
if(!visibleAccountingSections(can).length)return <section className="panel"><h1>Contabilidade</h1><p>Sem contexto autorizado para esta consulta.</p></section>;
|
||||
const active=accountingSections.find(s=>s.id===section);if(!active)return null;
|
||||
const unit=context.unit?.id??null;
|
||||
if(context.accounting?.single_book&&unit!==null&&can('general_ledger.view',unit))return <React.Suspense fallback={<p>A abrir Contabilidade…</p>}><ComposedAccountingWorkspace section={section} context={context} can={can} navigate={navigate} api={api}/></React.Suspense>;
|
||||
const action=f=>f.target&&(!f.unit||unit!==null)&&can(f.permission,f.unit?unit:null)?<button onClick={()=>navigate(f.target)}>{f.action}</button>:null;
|
||||
const functions=accountingFunctions.filter(f=>f.section===section);
|
||||
const report=accountingReports.find(r=>r.id===selected);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React,{useEffect,useState} from 'react';
|
||||
import {billingSections,visibleBillingSections,filterBillingRows,loadBillingRows} from './billing-menu.js';
|
||||
|
||||
const emptyFilters={search:'',type:'',currency:'',series:'',state:'',author:'',from:'',to:''};
|
||||
const BillingRegisterWorkspace=React.lazy(()=>import('./BillingRegisterWorkspace.jsx'));
|
||||
const value=v=>v===null||v===undefined||v===''?'Por determinar':String(v);
|
||||
|
||||
export function BillingDocumentList({rows,filters,onFilter,onClear,onOpen}) {
|
||||
@@ -25,13 +26,16 @@ export default function BillingMenuWorkspace({section,context,can,api,navigate})
|
||||
useEffect(()=>{setSelected(null);},[section]);
|
||||
useEffect(()=>{
|
||||
let current=true;setResult(null);setSelected(null);
|
||||
if(unit===null||active?.pending)return ()=>{current=false;};
|
||||
if(unit===null||active?.pending||(context.service_documents_enabled&&['billing_all','service_invoices'].includes(section)))return ()=>{current=false;};
|
||||
setLoading(true);
|
||||
loadBillingRows(api,can,unit).then(r=>{if(current){setResult(r);setLoading(false);}});
|
||||
return ()=>{current=false;};
|
||||
},[context.token,section,reload]);
|
||||
if(!active)return null;
|
||||
if(context.service_documents_enabled&&['billing_all','service_invoices'].includes(section))return <React.Suspense fallback={<p role="status">A abrir documentos…</p>}><BillingRegisterWorkspace api={api} context={context} navigate={navigate} section={section}/></React.Suspense>;
|
||||
const newDraft=()=>navigate('billing_drafts',null,true);
|
||||
const additional={billing_receipts:'receipt',billing_credit_notes:'credit_note',billing_invoice_receipts:'invoice_receipt'}[section];
|
||||
if(additional&&context.service_documents_enabled&&unit&&can('service_documents.view',unit))return <section className="panel"><p className="eyebrow">COMERCIAL</p><h1>{active.label}</h1><p>Preparação, revisão distinta, prévia e emissão individual de teste. Os recibos usam afectações efectivas; o crédito exige decisão financeira própria.</p><a href={'/commercial/documents?kind='+additional}>Abrir {active.label.toLowerCase()}</a><p>Sem validade fiscal e sem movimento de dinheiro real.</p></section>;
|
||||
return <div className="billing-menu-workspace"><div className="page-title"><p className="eyebrow">FACTURAÇÃO</p><h1>{active.label}</h1><p>Documentos comerciais no contexto de {context.company.name}{context.unit?` / ${context.unit.name}`:''}.</p></div>
|
||||
<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
<p className="fiscal-notice"><strong>Demonstração — sem validade fiscal.</strong> A entrada e a consulta não habilitam emissão real. Documento, dívida e recebimento são factos distintos.</p>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
const labels={invoice:'Factura de teste',invoice_receipt:'Factura-recibo',receipt:'Recibo',credit_note:'Nota de crédito',quote:'Orçamento',draft:'Rascunho'};
|
||||
const states={issued:'Emitido em teste',cancelled:'Anulado tecnicamente',draft:'Rascunho',approved:'Aprovado',rejected:'Recusado',refused:'Recusado',customer_accepted:'Aceite pelo cliente',customer_declined:'Recusado pelo cliente',converted:'Convertido',confirmed_internal:'Confirmado internamente',superseded:'Histórico / substituído'};
|
||||
export default function BillingRegisterWorkspace({api,context,navigate,section}){
|
||||
const initial={kind:section==='service_invoices'?'invoice':'',search:'',currency:'',series:'',author:'',state:'',from:'',to:'',per_page:'25'};
|
||||
const [form,setForm]=useState(initial),[query,setQuery]=useState({...initial,page:1}),[result,setResult]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
|
||||
useEffect(()=>{setForm(initial);setQuery({...initial,page:1});setResult(null);},[context.token,section]);
|
||||
useEffect(()=>{let current=true;setLoading(true);setError('');setResult(null);const p=new URLSearchParams(Object.entries(query).filter(([,v])=>v!==''));api('/api/service-documents/register?'+p).then(v=>{if(current)setResult(v);}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setLoading(false);});return()=>{current=false;};},[context.token,query]);
|
||||
const input=(key,value)=>setForm(v=>({...v,[key]:value}));const rows=result?.documents?.data??[],p=result?.documents;
|
||||
return <section className="panel"><p className="eyebrow">COMERCIAL</p><h1>{section==='service_invoices'?'Facturas':'Todos os documentos'}</h1><p>Consulta unificada por empresa, unidade e módulo. Factura-recibo representa a sua factura de origem uma única vez nesta lista. Rascunhos e orçamentos não são facturas.</p>
|
||||
<form onSubmit={e=>{e.preventDefault();setQuery({...form,page:1});}}><div className="billing-filters"><label>Tipo<select value={form.kind} onChange={e=>input('kind',e.target.value)}><option value="">Todos os autorizados</option>{Object.entries(labels).map(([v,t])=><option key={v} value={v}>{t}</option>)}</select></label><label>Número, cliente ou NUIT<input value={form.search} maxLength={100} onChange={e=>input('search',e.target.value)}/></label><label>Moeda<select value={form.currency} onChange={e=>input('currency',e.target.value)}><option value="">Todas</option><option value="MZN">MZN</option></select></label><label>Série exacta<input value={form.series} maxLength={100} onChange={e=>input('series',e.target.value)}/></label><label>Autor registado<input type="number" min="1" value={form.author} onChange={e=>input('author',e.target.value)}/></label><label>Estado<select value={form.state} onChange={e=>input('state',e.target.value)}><option value="">Todos</option>{Object.entries(states).map(([v,t])=><option value={v} key={v}>{t}</option>)}</select></label><label>Emissão desde<input type="date" value={form.from} onChange={e=>input('from',e.target.value)}/></label><label>Emissão até<input type="date" value={form.to} onChange={e=>input('to',e.target.value)}/></label><label>Por página<select value={form.per_page} onChange={e=>input('per_page',e.target.value)}><option value="25">25</option><option value="100">100</option></select></label></div><button disabled={loading}>Aplicar filtros</button><button type="button" disabled={loading} onClick={()=>{setForm(initial);setQuery({...initial,page:1});}}>Limpar filtros</button></form>
|
||||
{error&&<p role="alert">{error}</p>}{loading&&<p role="status">A consultar…</p>}{p&&<><p role="status">{p.total} registo(s); página {p.current_page} de {p.last_page}.</p><div className="table-scroll"><table><thead><tr><th>Referência / tipo</th><th>Cliente</th><th>Emissão</th><th>Montante documental</th><th>Estado</th><th>Saldo da factura de origem</th><th>Consultar</th></tr></thead><tbody>{rows.map(r=><tr key={r.kind+':'+r.source_id}><td>{r.reference}<small>{labels[r.kind]} · versão {r.version}</small></td><td>{r.client??'Por determinar'}{r.nuit&&<small>NUIT {r.nuit}</small>}</td><td>{r.document_on??'Não emitido'}</td><td>{r.amount!==null?`${r.amount} ${r.currency}`:r.base!==null?`Base ${r.base} ${r.currency}; imposto por conferir`:'Não determinado'}</td><td>{states[r.document_state]??r.document_state}</td><td>{r.balance_available?`${r.origin_invoice_balance} MZN`:r.invoice_id?'Sem consulta financeira autorizada ou sem reconhecimento':'Não aplicável'}</td><td>{r.pdf_url&&<a href={r.pdf_url} target="_blank" rel="noopener">PDF de teste</a>}{r.print_url&&<a href={r.print_url} target="_blank" rel="noopener">Orçamento desta versão</a>}{r.kind==='draft'&&<button onClick={()=>navigate('billing_drafts',r.source_id)}>Abrir rascunho</button>}{['receipt','credit_note','invoice_receipt'].includes(r.kind)&&<a href={'/commercial/documents?kind='+r.kind}>Histórico e decisões</a>}</td></tr>)}</tbody></table></div>{rows.length===0&&<p>Nenhum documento corresponde aos filtros autorizados.</p>}<button disabled={loading||p.current_page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior</button><button disabled={loading||p.current_page>=p.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte</button></>}
|
||||
<p className="help">Sem validade fiscal. Os montantes de facturas, recibos, créditos e bases não são somados. O saldo mostrado é o da factura de origem, podendo repetir-se em documentos ligados; não representa dinheiro recebido nem saldo próprio do recibo. Datas de emissão excluem rascunhos e orçamentos não emitidos.</p></section>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React,{useEffect,useMemo,useState}from'react';import{WorkshopStockOperation}from'./workshop-stock-operation.js';
|
||||
const qs=x=>new URLSearchParams(Object.fromEntries(Object.entries(x).filter(([,v])=>v!==''&&v!==null))).toString();
|
||||
function Options({api,view,label,value,onChange,disabled,context}){const[q,setQ]=useState({search:'',page:1,per_page:25}),[search,setSearch]=useState(''),[p,setP]=useState(null);useEffect(()=>{let active=true;api('/api/commercial-quotes/options?'+qs({view,...q})).then(v=>{if(active)setP(v.records);}).catch(()=>{if(active)setP(null);});return()=>{active=false;};},[context.token,view,q]);return <fieldset disabled={disabled}><label>Pesquisar {label}<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>{onChange('');setQ({...q,search,page:1});}}>Pesquisar</button><label>{label}<select required value={value} onChange={e=>onChange(e.target.value,p?.data.find(r=>r.id===Number(e.target.value)))}><option value="">Escolher…</option>{p?.data.map(r=><option key={r.id} value={r.id}>{r.name}</option>)}</select></label>{p&&<><button type="button" disabled={p.current_page<=1} onClick={()=>{onChange('');setQ({...q,page:q.page-1});}}>Anterior</button><button type="button" disabled={p.current_page>=p.last_page} onClick={()=>{onChange('');setQ({...q,page:q.page+1});}}>Seguinte</button></>}</fieldset>;}
|
||||
const labels={draft:'Preparado — por rever',approved:'Revisto — aguarda cliente',rejected:'Revisão recusada',customer_accepted:'Aceite pelo cliente',customer_declined:'Recusado pelo cliente',converted:'Convertido em rascunho'};
|
||||
export default function CommercialQuotesWorkspace({api,context,can,navigate,actorId}){
|
||||
const[query,setQuery]=useState({page:1,per_page:25,state:'',search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[form,setForm]=useState(null),[origin,setOrigin]=useState(null),[preview,setPreview]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[history,setHistory]=useState(null),[historyKey,setHistoryKey]=useState(null);const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),has=right=>can('commercial_quotes.'+right,context.unit?.id),locked=busy||command.uncertain;
|
||||
const load=async()=>setData((await api('/api/commercial-quotes?'+qs(query))).quotes);useEffect(()=>{let active=true;api('/api/commercial-quotes?'+qs(query)).then(v=>{if(active)setData(v.quotes);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[query,context.token]);
|
||||
function start(row=null,action='prepare'){setError('');setPreview(null);setOrigin(row);const content=row?.data;setForm({action,quote_key:row?.quote_key??crypto.randomUUID(),version:row?.version??0,reason:'',data:action==='prepare'?{client_id:content?.client_id??'',currency:'MZN',valid_until:content?.valid_until??'',source_order_id:content?.source_order_id??null,description:content?.description??'',lines:content?.lines?.map(l=>({service_id:l.service_id,quantity:l.quantity,unit_price:l.unit_price,description:l.description??''}))??[{service_id:'',quantity:'',unit_price:'',description:''}]}:action==='review'?{decision:''}:action==='customer'?{decision:'',representative:'',capacity:'',evidence:'',decided_on:''}:{}});}
|
||||
const update=(key,value)=>setForm(f=>({...f,data:{...f.data,[key]:value}}));const line=(i,key,value)=>setForm(f=>({...f,data:{...f.data,lines:f.data.lines.map((l,n)=>n===i?{...l,[key]:value}:l)}}));
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input=JSON.parse(JSON.stringify(form));if(input.action==='prepare'){input.data.client_id=Number(input.data.client_id);input.data.source_order_id=input.data.source_order_id?Number(input.data.source_order_id):null;input.data.lines=input.data.lines.map(l=>({...l,service_id:Number(l.service_id)}));}setPreview(await api('/api/commercial-quotes/preview','POST',input,undefined,crypto.randomUUID()));}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const r=retry?await command.retry():await command.send('/api/commercial-quotes/confirm',{preview:preview.preview},crypto.randomUUID());setNotice(`Orçamento v${r.version}: ${labels[r.state]}${r.replayed?' · recibo recuperado':''}.`);setForm(null);setPreview(null);await load();}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function viewHistory(key,page=1){try{const result=await api('/api/commercial-quotes/'+key+'/history?page='+page);setHistory(result.events);setHistoryKey(key);}catch(e){setError(e.message);}}
|
||||
return <section className="panel" data-sgf-operation-uncertain={preview||command.uncertain?'true':undefined}><h1>Orçamentos comerciais e de reparação</h1><p>Proposta própria, revisão distinta e decisão documentada do cliente. Os valores são base antes de impostos: a conferência fiscal ocorre no seu percurso. Aceitar não emite factura, autoriza trabalho ou cria dívida/stock.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<form onSubmit={e=>{e.preventDefault();setQuery({...query,search,page:1});}}><fieldset disabled={locked}><label>Cliente<input value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Estado<select value={query.state} onChange={e=>setQuery({...query,state:e.target.value,page:1})}><option value="">Todos</option>{Object.entries(labels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>setQuery({...query,per_page:Number(e.target.value),page:1})}><option value="25">25</option><option value="100">100</option></select></label><button>Pesquisar</button></fieldset></form>{has('prepare')&&<button disabled={locked||Boolean(form)} onClick={()=>start()}>Novo orçamento</button>}
|
||||
{data&&<><div className="table-scroll"><table><thead><tr><th>Referência/cliente</th><th>Base proposta</th><th>Validade/estado</th><th>Acções</th></tr></thead><tbody>{data.data.map(row=><tr key={row.id}><td>ORC-QA-{row.quote_key.slice(0,8).toUpperCase()}<small>{row.client_name} · v{row.version}</small></td><td>{row.data.base} MZN</td><td>{row.data.valid_until}<small>{row.expired?'Caducado · ':''}{labels[row.state]}</small></td><td><a href={'/commercial-quotes/'+row.quote_key+'/print?version='+row.version} target="_blank" rel="noopener noreferrer">Versão para impressão</a><button disabled={locked} onClick={()=>viewHistory(row.quote_key)}>Histórico</button>{!row.conversion&&has('prepare')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&<button disabled={locked||Boolean(form)} onClick={()=>start(row)}>Rever proposta</button>}{row.state==='draft'&&has('review')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&row.prepared_by!==actorId&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'review')}>Conferir comercialmente</button>}{row.state==='approved'&&has('prepare')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&!row.expired&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'customer')}>Registar decisão do cliente</button>}{row.state==='customer_accepted'&&has('convert')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&!row.expired&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'convert')}>Converter explicitamente</button>}{row.conversion&&navigate&&<button disabled={locked||Boolean(preview)} onClick={()=>navigate('billing_drafts',{area:'billing_drafts',id:row.conversion.billing_draft_id})}>Abrir o mesmo rascunho</button>}</td></tr>)}</tbody></table></div>{!data.data.length&&<p>Sem orçamentos visíveis para estes filtros.</p>}<button disabled={locked||data.current_page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Anterior</button><span> Página {data.current_page}/{data.last_page} </span><button disabled={locked||data.current_page>=data.last_page} onClick={()=>setQuery({...query,page:query.page+1})}>Seguinte</button></>}
|
||||
{form&&<section className="panel"><h2>{form.action==='prepare'?'Preparar proposta':form.action==='review'?'Revisão distinta':form.action==='customer'?'Decisão do cliente':'Conversão explícita'}</h2><form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}>
|
||||
{form.action==='prepare'?<><Options api={api} context={context} view="clients" label="Cliente" value={form.data.client_id} onChange={value=>update('client_id',value)}/><label>Validade até<input type="date" required value={form.data.valid_until} onChange={e=>update('valid_until',e.target.value)}/></label><label>Descrição<textarea required value={form.data.description} onChange={e=>update('description',e.target.value)}/></label>{can('workshop_billing.view',context.unit?.id)&&<><label>Origem<select value={form.data.source_order_id===null?'commercial':'workshop'} onChange={e=>update('source_order_id',e.target.value==='workshop'?'':null)}><option value="commercial">Proposta comercial</option><option value="workshop">Ordem de reparação</option></select></label>{form.data.source_order_id!==null&&<Options api={api} context={context} view="orders" label="Ordem de origem" value={form.data.source_order_id} onChange={(value,row)=>setForm(f=>({...f,data:{...f.data,source_order_id:value,client_id:row?.client_id??f.data.client_id}}))}/>}</>}{form.data.lines.map((l,i)=><section key={i}><h3>Linha {i+1}</h3><Options api={api} context={context} view="services" label="Serviço" value={l.service_id} onChange={value=>line(i,'service_id',value)}/><label>Quantidade<input required value={l.quantity} onChange={e=>line(i,'quantity',e.target.value)}/></label><label>Preço unitário proposto<input required value={l.unit_price} onChange={e=>line(i,'unit_price',e.target.value)}/></label><label>Descrição<input value={l.description??''} onChange={e=>line(i,'description',e.target.value)}/></label>{form.data.lines.length>1&&<button type="button" onClick={()=>update('lines',form.data.lines.filter((_,n)=>n!==i))}>Remover linha</button>}</section>)}<button type="button" disabled={form.data.lines.length>=100} onClick={()=>update('lines',[...form.data.lines,{service_id:'',quantity:'',unit_price:'',description:''}])}>Adicionar linha</button></>:<p>{origin?.client_name} · base {origin?.data.base} MZN · versão {form.version}.</p>}
|
||||
{form.action==='review'&&<label>Decisão<select required value={form.data.decision} onChange={e=>update('decision',e.target.value)}><option value="">Escolher…</option><option value="approved">Aprovar proposta</option><option value="rejected">Recusar proposta</option></select></label>}
|
||||
{form.action==='customer'&&<><label>Decisão do cliente<select required value={form.data.decision} onChange={e=>update('decision',e.target.value)}><option value="">Escolher…</option><option value="accepted">Aceite</option><option value="declined">Recusada</option></select></label>{[['representative','Representante'],['capacity','Qualidade/representação'],['evidence','Referência/evidência da decisão'],['decided_on','Data da decisão']].map(([k,label])=><label key={k}>{label}<input type={k==='decided_on'?'date':'text'} required value={form.data[k]} onChange={e=>update(k,e.target.value)}/></label>)}</>}
|
||||
{form.action==='convert'&&<p>Cria uma prestação e um rascunho ligados ao orçamento aceite, conservando preços e origem. Não emite factura, altera impostos ou lança dívida.</p>}
|
||||
<label>Fundamento<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm({...form,reason:e.target.value})}/></label><button>Conferir impacto</button></fieldset></form>{preview&&<div role="status"><h3>Impacto antes da decisão</h3><p>{preview.impact.reference} · {preview.impact.client} · base {preview.impact.base} MZN · {labels[preview.impact.state]}.</p>{preview.impact.lines.map((l,i)=><p key={i}>{l.service_name}: {l.quantity} × {l.unit_price} MZN</p>)}<p>Sem emissão, dívida ou Stock automáticos. Validade {preview.impact.valid_until}.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão</button></div>}{command.uncertain&&<button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button>}<button disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar</button></section>}
|
||||
{history&&<section className="panel"><h2>Histórico do orçamento</h2>{history.data.map(r=><article key={r.id}><strong>v{r.version} · {labels[r.state]}</strong><p>Autor #{r.actor_id} · {r.created_at} · {r.reason}</p></article>)}<button disabled={locked||history.current_page<=1} onClick={()=>viewHistory(historyKey,history.current_page-1)}>Anterior</button><button disabled={locked||history.current_page>=history.last_page} onClick={()=>viewHistory(historyKey,history.current_page+1)}>Seguinte</button></section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {accountingSections,visibleAccountingSections} from './accounting-menu.js';
|
||||
const LedgerReportsWorkspace=React.lazy(()=>import('./LedgerReportsWorkspace.jsx'));
|
||||
export default function ComposedAccountingWorkspace({section,context,can,navigate,api}){
|
||||
const active=accountingSections.find(s=>s.id===section),unit=context.unit.id;
|
||||
const links=<div className="actions"><a href="/general-ledger">Diário, origens e decisões</a><button onClick={()=>navigate('accounting_analysis')}>Balancete e razão por período</button><button onClick={()=>navigate('accounting_review')}>Conferências disponíveis</button></div>;
|
||||
return <div className="accounting-menu-workspace"><div className="page-title"><p className="eyebrow">CONTABILIDADE</p><h1>{active.label}</h1><p>{context.company.name} / {context.unit.name}</p></div><nav className="accounting-tabs" aria-label="Submenus de contabilidade">{visibleAccountingSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
{section==='accounting_analysis'?<React.Suspense fallback={<p role="status">A abrir relatórios…</p>}><LedgerReportsWorkspace key={context.token} context={context} api={api}/></React.Suspense>:<>
|
||||
{section==='accounting_panel'&&<section className="panel"><h2>Do documento ao relatório</h2><p>O diário reúne origens autorizadas de Compras, Comercial, Oficinas e Stock. Cada aplicação exige origem actual, preparação, revisão distinta e confirmação individual. Um documento preparado não é um lançamento aplicado.</p>{links}<h3>Como conferir</h3><ol><li>Abra a origem e a respectiva proposta no diário.</li><li>Confira a revisão, fundamento, data e contas.</li><li>Consulte o balancete no intervalo pretendido e abra o razão da conta.</li><li>Conserve as correcções e a ligação ao original; não elimine movimentos anteriores.</li></ol></section>}
|
||||
{section==='accounting_daily'&&<section className="panel"><h2>Lançamentos e origens</h2><p>No diário pode conferir propostas, revisões, lançamentos aplicados e correcções. A repetição de uma operação recupera o mesmo resultado; uma versão alterada exige nova conferência.</p>{links}<p>O acesso de consulta não concede preparação, revisão ou aplicação. As funções responsáveis mantêm os poderes já aprovados para o exercício QA.</p></section>}
|
||||
{section==='accounting_review'&&<section className="panel"><h2>Conferir com a fonte responsável</h2><p>Escolha o percurso cuja origem pretende conferir. As contas contabilísticas e as fontes auxiliares conservam os seus estados e permissões próprios.</p><a href="/general-ledger">Revisões, correcções e evidências no diário</a><div className="actions">{can('fiscal_assessments.view',unit)&&context.fiscal_qa_enabled&&<button onClick={()=>navigate('fiscal_iva')}>Livro e mapa interno de IVA QA</button>}{can('fiscal_rules.view',unit)&&context.fiscal_rules_qa_enabled&&<button onClick={()=>navigate('fiscal_rules')}>Versões e cálculos fiscais internos</button>}{can('bank_reconciliation.view',unit)&&context.bank_qa_enabled&&<button onClick={()=>navigate('bank_reconciliation')}>Escolher Financeiro e conferir extracto</button>}</div><p>Sem reconciliação bancária geral, apuramento integral de impostos ou eliminação automática de diferenças. Cada correspondência e decisão continua individual.</p></section>}
|
||||
{section==='accounting_closing'&&<section className="panel"><h2>Conferência do período</h2><p>O percurso existente no diário permite consultar factos, pendências, revisão distinta e estado do período QA. Consultar não fecha nem reabre o período. O fecho do exemplo foi ensaiado com reversão da transacção; a instalação conserva o estado já aprovado.</p><a href="/general-ledger">Abrir diário e conferência do período</a><p>Depreciações, câmbio, acréscimos, diferimentos e fecho legal da empresa exigem tratamento próprio; não são gerados por este ecrã. Reabertura permanece fora do âmbito autorizado.</p></section>}
|
||||
{section==='accounting_configuration'&&<section className="panel"><h2>Contas, classificações e regras</h2><a href="/general-ledger">Pesquisar contas no plano do livro integrado</a><div className="actions">{can('catalogue_accounting.view')&&<button onClick={()=>navigate('catalogue_accounting')}>Classificação contabilística e histórico</button>}{can('service_fiscal.view')&&<button onClick={()=>navigate('service_fiscal')}>Perfis fiscais e cobertura</button>}{can('fiscal_rules.view',unit)&&context.fiscal_rules_qa_enabled&&<button onClick={()=>navigate('fiscal_rules')}>Fontes e regras versionadas</button>}</div><p>Classificação, conta e regra fiscal são decisões diferentes. Uma referência legal por conferir não se transforma numa regra activa. Alterações estruturais exigem os direitos e revisões existentes.</p></section>}
|
||||
</>}
|
||||
<p className="help">Exercício local com contas e documentos de teste. Não representa demonstrações financeiras legais, emissão fiscal ou autorização de operações reais.</p></div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function FiscalCoverage({coverage}) {
|
||||
if(!coverage)return null;
|
||||
return <section className="panel" aria-label="Cobertura fiscal e financeira"><h2>O que está disponível nesta versão</h2><p>{coverage.notice}</p><p>A presença de fontes ou cálculos de ensaio não valida a fiscalidade da empresa. Não há entrega automática de declarações às autoridades.</p>
|
||||
<div className="table-wrap"><table><thead><tr><th>Área</th><th>Estado</th><th>Disponível no SGF</th><th>Por concluir</th></tr></thead><tbody>{[...coverage.taxes,...coverage.management].map(r=><tr key={r.id}><th scope="row">{r.label}</th><td>{r.state==='partial'?'Parcial — âmbito delimitado':'Por implementar'}</td><td>{r.available??'Sem motor operacional integrado'}</td><td>{r.pending}</td></tr>)}</tbody></table></div>
|
||||
<details><summary>Como são incorporadas e actualizadas as regras</summary><ol>{coverage.rule_lifecycle.map(step=><li key={step}>{step}</li>)}</ol><p>Uma mudança exige nova versão e conferência do impacto. Não altera automaticamente os documentos já emitidos nem concede novos acessos.</p></details>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React,{useEffect,useMemo,useState,useRef} from 'react';
|
||||
import {fiscalMapDownload} from './statement-download.js';
|
||||
import {WorkshopStockOperation} from './workshop-stock-operation.js';
|
||||
import {checkedObligations,obligationsPageCsv,obligationLabels,decisionLabels} from './fiscal-obligations-view.js';
|
||||
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
|
||||
export function FiscalObligationRows({data,onSelect,disabled}){return <div className="table-scroll"><table><thead><tr><th>Registo / versão</th><th>Condição aprovada actual</th><th>Prazo / referência</th><th>Responsável / evidência</th><th>Autor e fundamento da versão</th><th>Conferir</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.record_key}<small>v{r.version} · {decisionLabels[r.state]} · {r.data.operation}</small></td><td>{obligationLabels[r.effective?.administrative_status??'unapproved']}<small>Mapa interno #{r.data.form_id} · {r.data.period}</small>{!r.current_source_matches&&<strong>Origem alterada: decisão bloqueada.</strong>}</td><td>{r.effective?.due_on??'Prazo desconhecido'}<small>{r.days_overdue===null?'Atraso indeterminado':r.days_overdue+' dia(s) à referência '+data.reference_on}</small></td><td>{r.effective?'QA'+r.effective.responsible_id:'Por aprovar'}<small>{r.effective?.evidence??'Preparação não equivale a aprovação.'}</small>{r.effective?.delivery_evidence&&<small>SHA da evidência interna: {r.effective.delivery_evidence.export_sha256}. Entrega apenas simulada.</small>}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={disabled} onClick={()=>onSelect(r)}>Escolher este registo</button></td></tr>)}</tbody></table>{!data.records.data.length&&<p>Nenhum registo corresponde ao âmbito/filtros.</p>}</div>;}
|
||||
export default function FiscalObligationsWorkspace({context,api}){
|
||||
const initial={reference_on:'',period:'',status:'',due_state:'',due_from:'',due_to:'',per_page:'25'},[filters,setFilters]=useState(initial),[query,setQuery]=useState(null),[data,setData]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);
|
||||
const mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);
|
||||
const operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]),locked=busy||operation.uncertain;
|
||||
useEffect(()=>{if(!query)return;let active=true;setData(null);setBusy(true);setError('');api('/api/fiscal-obligations?'+qs(query)).then(r=>{if(active)setData(checkedObligations(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
|
||||
const choose=r=>{setSelected(r);setReceipt(null);setNotice('');};
|
||||
function start(op,review=false,newRecord=false){const target=newRecord?null:selected;if(newRecord)setSelected(null);const source=data.source,e=target?.effective;setPreview(null);setError('');setForm({action:review?'review':'prepare',record_key:target?.record_key??crypto.randomUUID(),version:target?.version??0,reason:'',data:review?{decision:''}:{operation:op,form_id:source.form_id,period:source.period,responsible_id:e?.responsible_id??'',due_on:e?.due_on??'',evidence:'',export_sha256:''},unknown:review?false:Boolean(e&&e.due_on===null)});}
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const{unknown,...input}=form;if(input.action==='prepare')input.data={...input.data,responsible_id:Number(input.data.responsible_id),due_on:unknown?null:input.data.due_on,export_sha256:input.data.operation==='delivery'?input.data.export_sha256:null};const key=crypto.randomUUID(),p=await api('/api/fiscal-obligations/preview','POST',input,undefined,key);setPreview({...p,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await operation.retry():await operation.send('/api/fiscal-obligations/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('v'+out.version+' · '+decisionLabels[out.state]+(out.replayed?' · recibo recuperado':''));setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!operation.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function downloadMap(){const token=context.token;setBusy(true);setError('');try{const result=await fiscalMapDownload({id:data.source.form_id,token,sha256:data.source.export_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([result.bytes],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-mapa-interno-IVA-QA.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setNotice('Mapa interno descarregado e SHA verificado; sem submissão.');}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
|
||||
const exportPage=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+obligationsPageCsv(data,query)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-obrigacoes-QA-pagina-'+data.records.current_page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/fiscal-obligations/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
return <section className="panel" data-sgf-operation-uncertain={form||preview||locked?'true':undefined}><p className="eyebrow">CONTABILIDADE</p><h1>Obrigações e prazos administrativos QA</h1><p>Prazo artificial e entrega apenas simulada ligados ao mapa interno revisto. Sem prazo legal, submissão à AT, apuramento ou pagamento. A referência classifica o prazo; não reconstrói um estado histórico.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery({...filters,page:1});}}><fieldset disabled={locked||Boolean(form)}><div className="form-grid"><label>Referência para atraso<input type="date" required value={filters.reference_on} onChange={e=>setFilters({...filters,reference_on:e.target.value})}/></label><label>Período da origem<input type="month" value={filters.period} onChange={e=>setFilters({...filters,period:e.target.value})}/></label><label>Estado administrativo<select value={filters.status} onChange={e=>setFilters({...filters,status:e.target.value})}><option value="">Todos</option>{Object.entries(obligationLabels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Situação do prazo<select value={filters.due_state} onChange={e=>setFilters({...filters,due_state:e.target.value})}><option value="">Todas</option><option value="unknown">Desconhecido</option><option value="overdue">Passou a data</option><option value="not_overdue">Data ainda não ultrapassada</option></select></label><label>Prazo desde<input type="date" required={Boolean(filters.due_to)} value={filters.due_from} onChange={e=>setFilters({...filters,due_from:e.target.value})}/></label><label>Prazo até<input type="date" required={Boolean(filters.due_from)} min={filters.due_from||undefined} value={filters.due_to} onChange={e=>setFilters({...filters,due_to:e.target.value})}/></label><label>Por página<select value={filters.per_page} onChange={e=>setFilters({...filters,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button>Consultar</button></fieldset></form>
|
||||
{data&&<>{data.blocked&&<p role="alert">{data.blocked} Pode consultar o histórico; não há origem válida para novas decisões.</p>}{data.source&&<p>Origem disponível para escolha: mapa interno #{data.source.form_id} v{data.source.form_version}, período{data.source.period}, {data.source.document_reference}. SHA da exportação: <code>{data.source.export_sha256}</code>. <button disabled={locked||Boolean(form)} onClick={downloadMap}>Consultar exportação interna existente</button></p>}{data.capabilities.prepare&&data.source&&!query.record_key&&<button disabled={locked||Boolean(form)} onClick={()=>start('establish',false,true)}>Nova obrigação deste mapa</button>}
|
||||
<p>Página{data.records.current_page} de{data.records.last_page} · {data.records.total} registo(s). O estado temporal do prazo é independente da entrega/cancelamento.</p><FiscalObligationRows data={data} onSelect={choose} disabled={locked||Boolean(form)}/><button disabled={locked||Boolean(form)||data.records.current_page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={locked||Boolean(form)||data.records.current_page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button><button disabled={locked||Boolean(form)} onClick={exportPage}>Exportar apenas esta página CSV</button>{query.record_key&&<button disabled={locked||Boolean(form)} onClick={()=>{setSelected(null);setQuery({...filters,page:1});}}>Voltar à lista actual</button>}
|
||||
{selected&&<section><h2>Registo escolhido: {selected.record_key} · v{selected.version}</h2><p>{decisionLabels[selected.state]} · proposta: {selected.data.operation}, data{selected.data.due_on??'desconhecida'}, responsável QA{selected.data.responsible_id} · {selected.data.evidence}</p><p>Impacto registado nesta versão: antes v{selected.impact.before.version}, {obligationLabels[selected.impact.before.effective?.administrative_status??'unapproved']}, prazo{selected.impact.before.effective?.due_on??'desconhecido'}; depois v{selected.impact.after.version}, {obligationLabels[selected.impact.after.effective?.administrative_status??'unapproved']}, prazo{selected.impact.after.effective?.due_on??'desconhecido'}.</p><button disabled={locked||Boolean(form)} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.source&&selected.current_source_matches&&<>{data.capabilities.prepare&&selected.effective?.administrative_status!=='cancelled'&&<>{selected.effective?<><button disabled={locked||Boolean(form)} onClick={()=>start('amend')}>Preparar correcção</button><button disabled={locked||Boolean(form)} onClick={()=>start('cancel')}>Preparar cancelamento</button>{selected.effective.administrative_status!=='delivery_simulated'&&<button disabled={locked||Boolean(form)} onClick={()=>start('delivery')}>Declarar entrega apenas simulada</button>}</>:<button disabled={locked||Boolean(form)} onClick={()=>start('establish')}>Nova preparação deste registo</button>}</>}{data.capabilities.review&&selected.state==='prepared'&&<button disabled={locked||Boolean(form)} onClick={()=>start('',true)}>Rever preparação alheia</button>}</>}</section>}</>}
|
||||
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='prepare'?'Preparação individual':'Revisão distinta'} · versão observada {form.version}</h2>{form.action==='review'?<label>Decisão<select required value={form.data.decision} onChange={e=>setForm(f=>({...f,data:{decision:e.target.value}}))}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label>:<><p>Operação: {form.data.operation} · mapa#{form.data.form_id} · período{form.data.period}.</p><label>Responsável QA<select required disabled={['cancel','delivery'].includes(form.data.operation)} value={form.data.responsible_id} onChange={e=>setForm(f=>({...f,data:{...f.data,responsible_id:Number(e.target.value)}}))}><option value="">Escolher…</option>{[13,14,15,16,17].map(id=><option key={id} value={id}>QA{id}</option>)}</select></label><label><input type="checkbox" disabled={['cancel','delivery'].includes(form.data.operation)} checked={form.unknown} onChange={e=>setForm(f=>({...f,unknown:e.target.checked}))}/>Prazo explicitamente desconhecido</label><label>Prazo artificial<input type="date" min="2000-01-01" max="2100-12-31" required={!form.unknown} disabled={form.unknown||['cancel','delivery'].includes(form.data.operation)} value={form.data.due_on} onChange={e=>setForm(f=>({...f,data:{...f.data,due_on:e.target.value}}))}/></label><label>Referência textual da evidência<textarea required minLength={10} maxLength={2000} value={form.data.evidence} onChange={e=>setForm(f=>({...f,data:{...f.data,evidence:e.target.value}}))}/></label>{form.data.operation==='delivery'&&<label>SHA escolhido da exportação interna<input required pattern="[a-f0-9]{64}" value={form.data.export_sha256} onChange={e=>setForm(f=>({...f,data:{...f.data,export_sha256:e.target.value}}))}/></label>}</>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
|
||||
{preview&&<section><h2>Impacto antes da confirmação</h2><p>Antes: v{preview.impact.before.version}, {obligationLabels[preview.impact.before.effective?.administrative_status??'unapproved']}, prazo{preview.impact.before.effective?.due_on??'desconhecido'}. Depois da decisão: v{preview.impact.after.version}, {decisionLabels[preview.impact.after.state]}, {obligationLabels[preview.impact.after.effective?.administrative_status??'unapproved']}, prazo{preview.impact.after.effective?.due_on??'desconhecido'}.</p><p>Proposta: {preview.impact.after.proposal.operation}, prazo{preview.impact.after.proposal.due_on??'desconhecido'}, responsável QA{preview.impact.after.proposal.responsible_id}, {preview.impact.after.proposal.evidence}.</p><p>Fundamento: {form.reason}. Zero dívida/pagamentos/lançamentos; sem submissão oficial.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}
|
||||
{operation.uncertain&&<><p role="alert">Resultado incerto. Conserve corpo, chave e decisão; não inicie outra.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
|
||||
<h2>Recibo próprio</h2><label>Chave da operação<input disabled={locked||Boolean(form)} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={locked||Boolean(form)||!receiptKey} onClick={recover}>Consultar recibo sem executar</button>{receipt&&<p>Recibo histórico v{receipt.result.version} · {decisionLabels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Nenhuma operação foi executada nesta consulta.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React,{useEffect,useMemo,useState,useRef} from 'react';
|
||||
import {statementDownload} from './statement-download.js';
|
||||
import {WorkshopStockOperation} from './workshop-stock-operation.js';
|
||||
import {minorAmount} from './ledger-report-view.js';
|
||||
import {checkedStatements,statementCategories,statementStates} from './internal-statements-view.js';
|
||||
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
|
||||
export function StatementGroups({summary}){const cards=(currency,c,categories)=><dl className="overview-definitions">{categories.map(category=>{const g=c.groups[category];return <div key={category}><dt>{statementCategories[category]} · {g.accounts} conta(s)</dt><dd>{g.presentation_minor===null?'Sem conta observada':minorAmount(g.presentation_minor)+' '+currency}</dd>{g.inverted_accounts>0&&<small>{g.inverted_accounts} saldo(s) invertido(s), sem usar valor absoluto.</small>}</div>;})}</dl>;return <section><h2>Rubricas internas e pendências</h2><p>Capital próprio: indeterminado. Modelo legal: não. As rubricas sem conta observada são distintas de um saldo zero observado.</p>{!Object.keys(summary.currencies).length&&<p>Sem contas com movimentos na fonte deste intervalo. Não se fabricam rubricas com saldo zero.</p>}{Object.entries(summary.currencies).map(([currency,c])=><section key={currency}><h3>{currency}</h3><h4>Balanço interno parcial</h4>{cards(currency,c,['asset','liability'])}<p>Capital e equação patrimonial completa: indeterminados. Balanço completo: não.</p><h4>Resultados internos parciais</h4>{cards(currency,c,['income','expense'])}<p>Subtotal das rubricas de resultados classificadas: {minorAmount(c.known_result_subtotal_minor)} {currency}. Não é resultado da empresa.</p><p>Resultado completo: {c.complete_result_minor===null?'Indeterminado por classificação pendente':minorAmount(c.complete_result_minor)+' '+currency+' no recorte QA'}.</p><h4>Contas por classificar</h4>{cards(currency,c,['unclassified'])}<p>{c.unclassified_accounts} conta(s) por classificar; o saldo conserva o sinal original.</p></section>)}</section>;}
|
||||
export function StatementLines({rows,dates}){return <div className="table-scroll"><table><thead><tr><th>Conta / rubrica</th><th>Abertura líquida</th><th>Débitos do período</th><th>Créditos do período</th><th>Fecho líquido original</th><th>Origem</th></tr></thead><tbody>{rows.map(r=><tr key={r.account_id}><td>{r.code}<small>{r.label} · {statementCategories[r.category]}</small></td>{['opening_net_minor','debit_minor','credit_minor','closing_net_minor'].map(k=><td key={k}>{minorAmount(String(r[k]))} {r.currency}</td>)}<td><a href={'/general-ledger/account-ledger?'+new URLSearchParams({...dates,account_id:r.account_id,per_page:25})}>Abrir razão desta conta e datas</a><a href="/general-ledger">Conferir diário e origens</a><small>ID da conta:{r.account_id}</small></td></tr>)}</tbody></table></div>;}
|
||||
export default function InternalStatementsWorkspace({api,context}){
|
||||
const[query,setQuery]=useState({view:'history',page:1,per_page:25}),[data,setData]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[accounts,setAccounts]=useState(null),[optionQuery,setOptionQuery]=useState({view:'accounts',page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[accountId,setAccountId]=useState(''),[category,setCategory]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);
|
||||
const mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);
|
||||
const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),locked=busy||command.uncertain;
|
||||
useEffect(()=>{let active=true;setData(null);setBusy(true);setError('');api('/api/general-ledger/statements?'+qs(query)).then(r=>{if(active)setData(checkedStatements(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
|
||||
useEffect(()=>{if(form?.kind!=='mapping'||form.action!=='prepare')return;let active=true;setAccounts(null);api('/api/general-ledger/statements?'+qs(optionQuery)).then(r=>{if(active)setAccounts(checkedStatements(r,optionQuery).records);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,optionQuery,form?.kind,form?.action]);
|
||||
function start(kind,action){setPreview(null);setError('');setReceipt(null);const latest=kind==='mapping'?data.mapping_latest:data.result_latest;setForm({kind,action,version:latest?.version??0,reason:'',data:action==='review'?{decision:''}:kind==='mapping'?{entries:(data.mapping_approved?.data.entries??[]).map(e=>({account_id:e.account_id,category:e.category})),evidence:''}:{mapping_id:'',from_on:'',to_on:'',per_page:25,evidence:''}});setAccountId('');setCategory('');}
|
||||
function addEntry(){const id=Number(accountId);if(!Number.isSafeInteger(id)||!category||!accounts?.data.some(a=>a.id===id))return;setForm(f=>({...f,data:{...f.data,entries:[...f.data.entries.filter(e=>e.account_id!==id),{account_id:id,category}]}}));setAccountId('');setCategory('');}
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input=structuredClone(form);if(input.kind==='result'&&input.action==='prepare'){input.data.mapping_id=Number(input.data.mapping_id);input.data.per_page=Number(input.data.per_page);}const key=crypto.randomUUID(),out=await api('/api/general-ledger/statements/preview','POST',input,undefined,key);setPreview({...out,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await command.retry():await command.send('/api/general-ledger/statements/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice(out.kind+' v'+out.version+' · '+statementStates[out.state]+(out.replayed?' · recibo recuperado':''));setForm(null);setPreview(null);setQuery({view:'history',page:1,per_page:query.per_page});}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/general-ledger/statements/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function download(){const token=context.token;setBusy(true);setError('');try{const result=await statementDownload({id:query.version_id,token,sha256:data.snapshot.csv_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([result.bytes],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-demonstracoes-internas-QA.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setNotice('CSV congelado descarregado e SHA verificado.');}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
|
||||
const kindLabel=k=>k==='mapping'?'Correspondências':'Resultado interno';
|
||||
return <section className="panel" data-sgf-operation-uncertain={form||preview||locked?'true':undefined}><p className="eyebrow">CONTABILIDADE</p><h1>Demonstrações internas parciais QA</h1><p>Balanço e resultados por correspondências explícitas revistas. Sem classificação automática de contas, capital fabricado ou modelo legal. Os lançamentos originais não são alterados.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
{data&&<><div className="actions"><button disabled={locked||Boolean(form)} onClick={()=>setQuery({view:'history',page:1,per_page:query.per_page})}>Histórico de versões</button>{data.capabilities.prepare&&<><button disabled={locked||Boolean(form)} onClick={()=>start('mapping','prepare')}>Preparar correspondências explícitas</button><button disabled={locked||Boolean(form)||!data.mapping_approved} onClick={()=>start('result','prepare')}>Preparar resultado com datas explícitas</button></>}{data.capabilities.review&&['mapping','result'].map(kind=>{const latest=kind==='mapping'?data.mapping_latest:data.result_latest;return latest?.state==='prepared'&&<button key={kind} disabled={locked||Boolean(form)} onClick={()=>start(kind,'review')}>Rever {kindLabel(kind).toLowerCase()} v{latest.version}</button>;})}</div>
|
||||
{query.view==='result'&&data.snapshot?<><p>Resultado conservado da preparação v{data.snapshot.result_version}, {data.snapshot.frozen_at}, de{data.snapshot.summary.query.from_on} até{data.snapshot.summary.query.to_on}; correspondências v{data.snapshot.summary.mapping_version}. SHA:<code>{data.snapshot.csv_sha256}</code>.</p><p>{data.current_source_matches?'A fonte actual confere com este resultado conservado.':'A fonte/correspondência actual difere; este resultado permanece histórico e não foi recalculado.'}</p><StatementGroups summary={data.snapshot.summary}/><StatementLines rows={data.records.data} dates={data.snapshot.summary.query}/><button disabled={locked||Boolean(form)} onClick={download}>Descarregar CSV congelado desta revisão</button></>:<div className="table-scroll"><table><thead><tr><th>Tipo / versão / estado</th><th>Autor / data / fundamento</th><th>Dados conservados</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{kindLabel(r.kind)} v{r.version}<small>{statementStates[r.state]}</small></td><td>QA{r.host_actor_id} · {r.created_at}<small>{r.reason}</small></td><td>{r.kind==='mapping'?<details><summary>Ver todas as correspondências desta versão</summary><ul>{r.data.entries.map(e=><li key={e.account_id}>{e.account.code} · {statementCategories[e.category]}</li>)}</ul></details>:<><p>{r.data.summary.query.from_on} a{r.data.summary.query.to_on} · {r.data.summary.accounts} contas da fonte · {r.data.evidence}</p>{r.state==='approved'&&<button disabled={locked||Boolean(form)} onClick={()=>setQuery({view:'result',version_id:r.id,page:1,per_page:query.per_page})}>Abrir resultado revisto desta versão</button>}</>}</td></tr>)}</tbody></table></div>}
|
||||
<p>Página{data.records.current_page} de{data.records.last_page} · {data.records.total} linha(s). Totais do resultado conservado abrangem todas as páginas da fonte; a tabela é paginada.</p><button disabled={locked||Boolean(form)||data.records.current_page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior</button><button disabled={locked||Boolean(form)||data.records.current_page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte</button><label>Por página<select disabled={locked||Boolean(form)} value={query.per_page} onChange={e=>setQuery(q=>({...q,per_page:Number(e.target.value),page:1}))}><option value="25">25</option><option value="100">100</option></select></label></>}
|
||||
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='prepare'?'Preparação':'Revisão distinta'} · {kindLabel(form.kind)} · versão observada{form.version}</h2>{form.action==='review'?<><p>Confira os dados completos da preparação no histórico antes de escolher a decisão. A prévia apresenta os mesmos dados e o impacto.</p><label>Decisão<select required value={form.data.decision} onChange={e=>setForm(f=>({...f,data:{decision:e.target.value}}))}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label></>:<>{form.kind==='mapping'?<><p>Não há rubrica automática para conta nova. As escolhas da revisão anterior, quando existem, são apenas a base explícita desta correcção.</p><label>Pesquisar código/designação<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,search,page:1}));}}>Pesquisar contas</button><label>Conta observada<select value={accountId} onChange={e=>setAccountId(e.target.value)}><option value="">Escolher…</option>{accounts?.data.map(a=><option key={a.id} value={a.id}>{a.code} · {a.label}</option>)}</select></label><label>Rubrica explícita<select value={category} onChange={e=>setCategory(e.target.value)}><option value="">Escolher…</option>{Object.entries(statementCategories).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><button type="button" disabled={!accountId||!category||form.data.entries.length>=100} onClick={addEntry}>Adicionar/substituir esta correspondência</button>{accounts&&<><button type="button" disabled={accounts.current_page<=1} onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,page:q.page-1}));}}>Contas anteriores</button><button type="button" disabled={accounts.current_page>=accounts.last_page} onClick={()=>{setAccountId('');setOptionQuery(q=>({...q,page:q.page+1}));}}>Contas seguintes</button></>}<ul>{form.data.entries.map(e=><li key={e.account_id}>Conta#{e.account_id} · {statementCategories[e.category]} <button type="button" onClick={()=>setForm(f=>({...f,data:{...f.data,entries:f.data.entries.filter(v=>v.account_id!==e.account_id)}}))}>Retirar desta proposta</button></li>)}</ul></>:<><label>Versão revista de correspondências<select required value={form.data.mapping_id} onChange={e=>setForm(f=>({...f,data:{...f.data,mapping_id:e.target.value}}))}><option value="">Escolher…</option>{data.mapping_approved&&<option value={data.mapping_approved.id}>Versão{data.mapping_approved.version}</option>}</select></label>{['from_on','to_on'].map(k=><label key={k}>{k==='from_on'?'Desde':'Até'}<input type="date" required value={form.data[k]} onChange={e=>setForm(f=>({...f,data:{...f.data,[k]:e.target.value}}))}/></label>)}<label>Página de leitura da fonte<select value={form.data.per_page} onChange={e=>setForm(f=>({...f,data:{...f.data,per_page:Number(e.target.value)}}))}><option value="25">25</option><option value="100">100</option></select></label></>}<label>Evidência/pressupostos<textarea required minLength={10} maxLength={2000} value={form.data.evidence} onChange={e=>setForm(f=>({...f,data:{...f.data,evidence:e.target.value}}))}/></label></>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto antes de confirmar</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
|
||||
{preview&&<section><h2>Impacto a confirmar</h2><p>Antes v{preview.impact.before.version} · {statementStates[preview.impact.before.state]??'Sem versão'}; depois v{preview.impact.after.version} · {statementStates[preview.impact.after.state]}. Zero lançamentos e contas alterados. Fundamento:{form.reason}.</p>{preview.impact.kind==='mapping'?<ul>{preview.impact.data.entries.map(e=><li key={e.account_id}>{e.account.code} · {statementCategories[e.category]}</li>)}</ul>:<><StatementGroups summary={preview.impact.data.summary}/><p>SHA da exportação congelada:{preview.impact.data.csv_sha256}</p></>}<button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria:<code>{preview.key}</code></p></section>}
|
||||
{command.uncertain&&<><p role="alert">Resultado incerto. Conserve chave e decisão; não inicie outra operação.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
|
||||
<h2>Recibo próprio</h2><label>Chave<input disabled={locked||Boolean(form)} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={locked||Boolean(form)||!receiptKey} onClick={recover}>Consultar recibo sem executar</button>{receipt&&<p>Recibo histórico {receipt.result.kind} v{receipt.result.version} · {statementStates[receipt.result.state]} · {receipt.created_at}. {receipt.reason}. Sem execução nesta consulta.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {minorAmount,ledgerReportQuery,checkedReport,reportBasis,reportPageCsv} from './ledger-report-view.js';
|
||||
|
||||
export function LedgerReportTable({data,onAccount}){
|
||||
const trial=data.kind==='trial_balance';return <div className="table-scroll"><table><thead><tr>{(trial?['Conta','Designação','Moeda','Abertura líquida','Débito','Crédito','Fecho líquido','Consultar']:['Data / lançamento','Origem e fundamento','Débito','Crédito','Saldo progressivo']).map(t=><th key={t}>{t}</th>)}</tr></thead><tbody>{data.rows.map(r=>trial?<tr key={r.account_id}><td>{r.code}</td><td>{r.label}</td><td>{r.currency}</td><td>{minorAmount(r.opening_net_minor)}</td><td>{minorAmount(r.debit_minor)}</td><td>{minorAmount(r.credit_minor)}</td><td>{minorAmount(r.closing_net_minor)}</td><td><button onClick={()=>onAccount(r.account_id,r.code+' · '+r.label)}>Abrir razão desta conta</button></td></tr>:<tr key={r.line_id}><td>{r.journal_on}<small>Lançamento #{r.journal_id} · linha #{r.line_id}</small></td><td>{r.reason}<small>Proposta #{r.proposal_id} · revisão #{r.review_id} · {r.effect_type??'Origem no diário'}</small></td><td>{minorAmount(r.debit_minor)}</td><td>{minorAmount(r.credit_minor)}</td><td>{minorAmount(r.balance_minor)}</td></tr>)}</tbody></table>{!data.rows.length&&<p>Nenhum movimento elegível para a consulta autorizada e as datas escolhidas.</p>}</div>;
|
||||
}
|
||||
export default function LedgerReportsWorkspace({context,api}){
|
||||
const initial={kind:'trial-balance',from_on:'',to_on:'',account_id:'',per_page:'25'};
|
||||
const [form,setForm]=useState(initial),[query,setQuery]=useState(null),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);const basis=useRef(null);
|
||||
useEffect(()=>{let current=true;setData(null);setError('');if(!query)return()=>{current=false;};setLoading(true);const expected=query.params.page>1?basis.current:null;api('/api/general-ledger/'+query.kind+'?'+new URLSearchParams(query.params)).then(value=>{const result=checkedReport(value,query,expected);if(current){basis.current=reportBasis(result);setData(result);}}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setLoading(false);});return()=>{current=false;};},[context.token,query]);
|
||||
const load=(value,page=1)=>{try{const q=ledgerReportQuery(value,page);if(page===1)basis.current=null;setQuery(q);setError('');}catch(e){setError(e.message);}};
|
||||
const account=(id,label)=>{const value={...form,...data.query,kind:'account-ledger',account_id:String(id),account_label:label};setForm(value);load(value);};
|
||||
const download=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+reportPageCsv(data)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-'+data.kind+'-pagina-'+data.pagination.page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
return <section className="panel"><h2>Balancete e razão por período</h2><p>Escolha datas explícitas. Abertura considera lançamentos anteriores ao início; movimentos abrangem o intervalo e o fecho inclui o último dia. Só entram lançamentos aplicados.</p><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();load(form);}}><div className="form-grid"><label>Consulta<select value={form.kind} onChange={e=>setForm({...form,kind:e.target.value})}><option value="trial-balance">Balancete</option><option value="account-ledger">Razão de uma conta</option></select></label><label>Desde<input type="date" required value={form.from_on} onChange={e=>setForm({...form,from_on:e.target.value})}/></label><label>Até<input type="date" required value={form.to_on} onChange={e=>setForm({...form,to_on:e.target.value})}/></label><label>Conta seleccionada<input readOnly value={form.account_label??(form.account_id?'Conta #'+form.account_id:'Todas as contas do balancete')}/></label><label>Por página<select value={form.per_page} onChange={e=>setForm({...form,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button disabled={loading}>Consultar</button><button type="button" disabled={loading} onClick={()=>{const next={...form,kind:'trial-balance',account_id:'',account_label:undefined};setForm(next);load(next);}}>Consultar todas as contas</button></form>
|
||||
{loading&&<p role="status">A consultar o livro autorizado…</p>}{error&&<div role="alert"><p>{error}</p><p>A falha não significa saldo zero.</p>{query&&<button onClick={()=>load({...query.params,kind:query.kind})}>Actualizar a primeira página</button>}</div>}{data&&<><h3>{data.kind==='trial_balance'?'Balancete':`Razão · ${data.account.code} · ${data.account.label}`}</h3><p>{data.query.from_on} a {data.query.to_on} · {data.currency} · página {data.pagination.page} de {data.pagination.last_page} · {data.pagination.total} registo(s).</p><dl className="overview-definitions">{[['Débitos do intervalo',data.totals.debit_minor],['Créditos do intervalo',data.totals.credit_minor],...(data.kind==='account_ledger'?[['Abertura líquida da conta',data.opening_net_minor],['Fecho líquido da conta',data.closing_net_minor]]:[])].map(([label,v])=><div key={label}><dt>{label}</dt><dd>{minorAmount(v)} {data.currency}</dd></div>)}</dl><p>Totais do filtro completo devolvidos pelo servidor; não são a soma apenas das linhas visíveis. No balancete, saldo líquido positivo é devedor; negativo é credor.</p><LedgerReportTable data={data} onAccount={account}/><div className="actions"><button disabled={loading||data.pagination.page<=1} onClick={()=>load({...data.query,kind:query.kind},data.pagination.page-1)}>Anterior</button><button disabled={loading||data.pagination.page>=data.pagination.last_page} onClick={()=>load({...data.query,kind:query.kind},data.pagination.page+1)}>Seguinte</button><button onClick={download}>Exportar apenas esta página CSV</button><a href={'/general-ledger/'+query.kind+'?'+new URLSearchParams(query.params)} target="_blank" rel="noopener">Abrir nova leitura para impressão</a></div><p className="help">Referência do livro: {data.basis.journals} lançamento(s), último #{data.basis.last_journal_id}, até {data.basis.through_on}. O saldo progressivo do razão inclui as páginas anteriores. Se o livro mudar entre páginas, exige-se nova consulta. Exportar não arquiva nem certifica um relatório.</p></>}
|
||||
<p className="help">Contas artificiais do exercício local. Sem balanço, demonstração de resultados ou classificação legal inferida. Consultar e exportar esta página não gera lançamentos, ajustamentos ou fecho.</p></section>;
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
import React from 'react';
|
||||
import React,{useEffect,useRef} from 'react';
|
||||
|
||||
const sections = {
|
||||
human_resources:['human_resources'],
|
||||
commercial:['services','billing','billing_support'], purchasing:['purchasing'], stock:['stock'],
|
||||
workshop:['workshop'], finance:['receivables','costs'], accounting:['accounting'],
|
||||
workshop:['workshop'], finance:['treasury','receivables','costs'], accounting:['accounting'],
|
||||
};
|
||||
export function moduleGroups(groups, module) {
|
||||
if(!sections[module])return [];
|
||||
const own=groups.filter(g => sections[module].includes(g.id));
|
||||
if(module==='commercial')return own;
|
||||
if(['commercial','human_resources'].includes(module))return own;
|
||||
const shared=groups.flatMap(g=>g.items).filter(i=>['clients','services','service_drafts','billing_drafts'].includes(i.id));
|
||||
return shared.length?[...own,{id:'shared_records',label:'Cadastros e rascunhos do módulo',items:shared}]:own;
|
||||
}
|
||||
export function moduleFunctionAllowed(context,groups,module,id){return Boolean(context?.modules?.some(m=>m.id===module)&&(id==='home'||moduleGroups(groups,module).some(g=>g.items.some(i=>i.id===id))));}
|
||||
|
||||
export default function ModuleNavigation({groups, context, page, navigate, changeModule, user, busy, logout}) {
|
||||
export default function ModuleNavigation({groups, context, page, navigate, openPage, user, busy, logout}) {
|
||||
const active=context?.modules?.find(m=>m.id===context.module_id);
|
||||
const own=moduleGroups(groups,active?.id);
|
||||
const menu=useRef(null);
|
||||
useEffect(()=>{const close=e=>{if(!menu.current?.contains(e.target))for(const d of menu.current?.querySelectorAll('details[open]')??[])d.open=false;};window.addEventListener('pointerdown',close);return()=>window.removeEventListener('pointerdown',close);},[]);
|
||||
const choose=(event,module,id)=>{event.currentTarget.closest('details').open=false;openPage(module,id);};
|
||||
return <div className="module-navigation">
|
||||
<div className="module-topbar"><button className="topbar-brand" onClick={()=>navigate('home')}>TEMPO <span>SGF</span></button>
|
||||
<nav aria-label="Menu principal"><label>Módulo activo<select aria-label="Módulo activo" value={active?.id||''} disabled={busy} onChange={e=>changeModule(e.target.value||null)}><option value="">Escolher módulo</option>{context?.modules?.map(m=><option key={m.id} value={m.id}>{m.label}{m.development?' · desenvolvimento':''}</option>)}</select></label></nav>
|
||||
<nav aria-label="Opções gerais"><button aria-current={page==='home'?'page':undefined} onClick={()=>navigate('home')}>Início</button>{groups.find(g=>g.id==='admin')?.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}</button>)}</nav>
|
||||
<div className="topbar-account"><strong>{user.name}</strong><button disabled={busy} onClick={logout}>Sair</button></div>
|
||||
<div className="topbar-account"><strong>{user.name}</strong>{context.single_installation&&<a href="/sgf">Trocar função QA</a>}<button disabled={busy} onClick={logout}>Sair</button></div>
|
||||
</div>
|
||||
<nav className="module-menu" aria-label={active?`Navegação de ${active.label}`:'Navegação do módulo'}><strong>{active?.label||'Nenhum módulo seleccionado'}</strong>{own.map(g=><details key={g.id}><summary>{g.label}</summary><div>{g.items.map(i=><button key={i.id} aria-current={page===i.id?'page':undefined} onClick={()=>navigate(i.id)}>{i.label}{i.informational?' · Em preparação':''}</button>)}</div></details>)}{active&&!own.length&&<span>Sem funcionalidades autorizadas neste contexto.</span>}</nav>
|
||||
<nav ref={menu} className="module-menu module-dropdowns" aria-label="Módulos e funções" onKeyDown={event=>{if(event.key==='Escape'){const d=event.target.closest('details');if(d){d.open=false;d.querySelector('summary')?.focus();}}}}>
|
||||
{context?.modules?.map(module=><details key={module.id} name="sgf-modules" className={active?.id===module.id?'active-module':''}><summary aria-current={active?.id===module.id?'true':undefined}>{module.label}<span aria-hidden="true">▾</span></summary><div aria-label={`Funções de ${module.label}`}><p className="module-dropdown-title">{module.label}</p>{moduleGroups(groups,module.id).map(group=><section key={group.id}><h2>{group.label}</h2>{group.items.map(item=><button key={item.id} disabled={busy} aria-current={active?.id===module.id&&page===item.id?'page':undefined} onClick={event=>choose(event,module.id,item.id)}>{item.label}</button>)}</section>)}</div></details>)}
|
||||
{!context?.modules?.length&&<span>Sem módulos autorizados neste contexto.</span>}
|
||||
</nav>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import React,{useEffect,useMemo,useState} from 'react';
|
||||
import {WorkshopStockOperation} from './workshop-stock-operation.js';
|
||||
import {minorAmount} from './ledger-report-view.js';
|
||||
export const dueLabels={prepared:'Preparado — por rever',approved:'Aprovado',refused:'Recusado'};
|
||||
export function DueFacts({data}){return <><p>{data.basis.document.number} · {data.basis.document.supplier_name}</p><p>Saldo actual {minorAmount(String(data.basis.position.balance_minor))} {data.basis.position.currency}. Vencimento original: {data.basis.position.due_on??'Desconhecido'}.</p><p>Condição administrativa revista: {data.reviewed_terms?.due_on??'Desconhecida'} · {data.days_overdue===null?'Atraso indeterminado':data.days_overdue+' dia(s) de atraso'} à referência {data.reference_on}.</p>{data.reviewed_terms&&<p>Evidência: {data.reviewed_terms.evidence}</p>}</>;}
|
||||
export default function PayableDueWorkspace({row,api,context,onClose}){
|
||||
const[reference,setReference]=useState(''),[query,setQuery]=useState(null),[data,setData]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);
|
||||
const operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]);const locked=busy||operation.uncertain;
|
||||
useEffect(()=>{if(!query)return;let active=true;setBusy(true);setError('');api('/api/finance/payable-due?'+new URLSearchParams({obligation_id:row.obligation_id,...query})).then(v=>{if(active)setData(v);}).catch(e=>{if(active){setError(e.message);setData(null);}}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,row.obligation_id,query]);
|
||||
function start(action){setError('');setReceipt(null);setPreview(null);setForm({action,obligation_id:row.obligation_id,version:data.basis.version,reason:'',data:action==='prepare'?{due_on:'',evidence:''}:{decision:''},unknown:false});}
|
||||
async function previewForm(e){e.preventDefault();setBusy(true);setError('');try{const{unknown,...input}=form;if(input.action==='prepare')input.data={...input.data,due_on:unknown?null:input.data.due_on};const key=crypto.randomUUID();const out=await api('/api/finance/payable-due/preview','POST',input,undefined,key);setPreview({...out,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await operation.retry():await operation.send('/api/finance/payable-due/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('Versão '+out.version+': '+dueLabels[out.state]+(out.replayed?' · recibo recuperado':''));setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!operation.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function recoverReceipt(){setBusy(true);setError('');try{setReceipt(await api('/api/finance/payable-due/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
return <section className="panel" data-sgf-operation-uncertain={form||preview||locked?'true':undefined}><h1>Vencimento administrativo de fornecedor</h1><p>Documento escolhido: {row.reference}. Condição individual revista, separada da factura e do livro monetário. Não cria pagamento, juros ou lançamento.</p><button disabled={locked||Boolean(form)} onClick={onClose}>Voltar às posições</button>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setQuery({reference_on:reference,page:1,per_page:25});}}><fieldset disabled={locked||Boolean(form)}><label>Referência explícita para atraso<input type="date" required value={reference} onChange={e=>setReference(e.target.value)}/></label><button>Consultar condições e histórico</button></fieldset></form>
|
||||
{data&&<><DueFacts data={data}/><p>{data.notice}</p><p>Versão actual {data.basis.version} · {dueLabels[data.basis.last?.state]??'Sem preparação'}.</p>{data.capabilities.prepare&&<button disabled={locked||Boolean(form)} onClick={()=>start('prepare')}>Preparar condição individual</button>}{data.capabilities.review&&data.basis.last?.state==='prepared'&&<button disabled={locked||Boolean(form)} onClick={()=>start('review')}>Rever preparação actual</button>}
|
||||
<h2>Histórico auditado</h2><div className="table-scroll"><table><thead><tr><th>Versão / estado</th><th>Condição / evidência</th><th>Autor / data / fundamento</th></tr></thead><tbody>{data.history.data.map(v=><tr key={v.id}><td>{v.version} · {dueLabels[v.state]}</td><td>{v.data.due_on??'Desconhecido'}<small>{v.data.evidence}</small></td><td>QA{v.host_actor_id} · {v.created_at}<small>{v.reason}</small></td></tr>)}</tbody></table></div><p>Página {data.history.current_page} de {data.history.last_page}, {data.history.total} evento(s).</p><button disabled={locked||Boolean(form)||data.history.current_page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Histórico anterior</button><button disabled={locked||Boolean(form)||data.history.current_page>=data.history.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Histórico seguinte</button><label>Eventos por página<select disabled={locked||Boolean(form)} value={query.per_page} onChange={e=>setQuery(q=>({...q,per_page:Number(e.target.value),page:1}))}><option value="25">25</option><option value="100">100</option></select></label></>}
|
||||
{form&&<form onSubmit={previewForm}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='prepare'?'Preparação individual':'Revisão distinta'} · versão observada {form.version}</h2>{form.action==='prepare'?<><label><input type="checkbox" checked={form.unknown} onChange={e=>setForm(f=>({...f,unknown:e.target.checked}))}/>Declarar explicitamente vencimento desconhecido</label><label>Data proposta<input type="date" required={!form.unknown} disabled={form.unknown} min="2000-01-01" max="2100-12-31" value={form.data.due_on} onChange={e=>setForm(f=>({...f,data:{...f.data,due_on:e.target.value}}))}/></label><label>Referência da evidência<input required minLength={10} maxLength={1000} value={form.data.evidence} onChange={e=>setForm(f=>({...f,data:{...f.data,evidence:e.target.value}}))}/></label></>:<><p>Preparação a conferir: {JSON.parse(data.basis.last.data).due_on??'Desconhecido'} · {JSON.parse(data.basis.last.data).evidence}</p><label>Decisão<select required value={form.data.decision} onChange={e=>setForm(f=>({...f,data:{decision:e.target.value}}))}><option value="">Escolher…</option><option value="approved">Aprovar condição</option><option value="refused">Recusar preparação</option></select></label></>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto antes de confirmar</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário / prévia</button></form>}
|
||||
{preview&&<section><h2>Impacto a confirmar</h2><p>Antes: v{preview.impact.before.version}, condição revista {preview.impact.before.reviewed_terms?.due_on??'Desconhecida'}. Depois: v{preview.impact.after.version}, {dueLabels[preview.impact.after.state]}, condição revista {preview.impact.after.reviewed_terms?.due_on??'Desconhecida'}.</p><p>Proposta: {preview.impact.after.proposal.due_on??'Desconhecido'} · {preview.impact.after.proposal.evidence}</p><p>Saldo mantém {minorAmount(preview.impact.balance_minor)} {preview.impact.currency}; zero pagamentos e zero lançamentos. Fundamento: {form.reason}.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave de recuperação: <code>{preview.key}</code></p></section>}
|
||||
{operation.uncertain&&<><p role="alert">Resultado incerto. Conserve esta decisão e a chave; não prepare outra operação.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
|
||||
<h2>Recibo de uma operação própria</h2><label>Chave da operação<input value={receiptKey} disabled={locked||Boolean(form)} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={locked||Boolean(form)||!receiptKey} onClick={recoverReceipt}>Consultar recibo sem executar</button>{receipt&&<p>Recibo histórico v{receipt.result.version} · {dueLabels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Nenhuma operação executada nesta consulta.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import PayableDueWorkspace from './PayableDueWorkspace.jsx';
|
||||
import {minorAmount} from './ledger-report-view.js';
|
||||
import {documentStates,payableStates,payablePageSummary,payableAgeSummary,ageLabels,checkedPayablePage,payablePageCsv} from './payable-position-view.js';
|
||||
const amount=v=>v===null?'Indeterminado':minorAmount(v);
|
||||
export function PayablePositionRows({data,onDue}){return <div className="table-scroll"><table><thead><tr><th>Documento e fornecedor</th><th>Conferência / reconhecimento</th><th>Principal</th><th>Histórico confirmado / afectações</th><th>Saldo actual</th><th>Vencimento / pendências</th><th>Consultar</th></tr></thead><tbody>{data.positions.map(r=><tr key={r.document_id}><td>{r.reference}<small>{r.supplier}</small><small>{r.currency}</small></td><td>{documentStates[r.document_state]??r.document_state}<small>{payableStates[r.payment_state]??r.payment_state}</small></td><td>{amount(r.principal_minor)}</td><td>{amount(r.confirmed_payments_minor)} / {amount(r.allocated_payments_minor)}</td><td>{amount(r.balance_minor)}</td><td>Original: {r.due_on??'Vencimento desconhecido'}{r.reviewed_terms&&<small>Condição administrativa revista v{r.reviewed_terms.version}: {r.reviewed_terms.due_on??'Desconhecida'}. Consulte a referência de atraso e o histórico.</small>}<small>{r.days_overdue===null?'Atraso indeterminado pelo livro':r.days_overdue+' dia(s) segundo o livro'}</small>{r.reviewed_age_bucket&&<small>{ageLabels[r.reviewed_age_bucket]}{r.reviewed_age_days===null?'':' · '+r.reviewed_age_days+' dia(s) à referência '+data.aging_reference_on}</small>}{r.source_conflict&&<strong>Origem/histórico divergente: saldo não presumido.</strong>}<ul>{r.pending.map((p,i)=><li key={i}>{p}</li>)}</ul></td><td>{r.obligation_id?<><a href={'/finance/payables?document_id='+r.document_id}>Abrir esta obrigação e comprovativos</a>{onDue&&<button onClick={()=>onDue(r)}>Condições de vencimento e histórico</button>}</>:<span>Reconhecimento ainda pendente</span>}</td></tr>)}</tbody></table>{!data.positions.length&&<p>Nenhum documento corresponde aos filtros autorizados nesta página.</p>}</div>;}
|
||||
export default function PayablePositionsWorkspace({context,api,aging=false}){
|
||||
const[selected,setSelected]=useState(null);
|
||||
const initial={search:'',document_state:'',per_page:'25',reference_on:''};const [form,setForm]=useState(initial),[query,setQuery]=useState({...initial,page:1}),[data,setData]=useState(null),[loading,setLoading]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let current=true;setData(null);setError('');if(aging&&!query.reference_on){setLoading(false);return()=>{current=false;};}setLoading(true);api('/api/finance/payable-positions?'+new URLSearchParams(Object.entries(query).filter(([,v])=>v!==''))).then(v=>{const result=checkedPayablePage(v,query);if(current)setData(result);}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setLoading(false);});return()=>{current=false;};},[context.token,query,aging]);
|
||||
const summary=data?payablePageSummary(data):null;const exportPage=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+payablePageCsv(data,query)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-fornecedores-pagina-'+data.pagination.page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
if(selected)return <PayableDueWorkspace row={selected} api={api} context={context} onClose={()=>setSelected(null)}/>;
|
||||
return <section className="panel"><p className="eyebrow">FINANCEIRO</p><h1>{aging?'Mapa de antiguidade de fornecedores':'Posições de fornecedores'}</h1>{aging&&<p>Classificação do saldo actual segundo a condição administrativa revista e uma referência escolhida. Totais apenas da página, por moeda; não é saldo histórico, prazo legal ou recomendação de pagamento.</p>}<p>Saldo, pagamentos históricos e afectações provêm do mesmo livro de obrigações. Uma factura pendente ou sem reconhecimento não é dívida zero. Não são criados pagamentos ou lançamentos por esta consulta.</p><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setQuery({...form,page:1});}}><div className="form-grid"><label>Referência para faixas administrativas<input type="date" required={aging} value={form.reference_on} onChange={e=>setForm({...form,reference_on:e.target.value})}/></label><label>Fornecedor ou número do documento<input type="search" maxLength={100} value={form.search} onChange={e=>setForm({...form,search:e.target.value})}/></label><label>Estado do documento<select value={form.document_state} onChange={e=>setForm({...form,document_state:e.target.value})}><option value="">Todos</option>{Object.entries(documentStates).map(([v,t])=><option key={v} value={v}>{t}</option>)}</select></label><label>Por página<select value={form.per_page} onChange={e=>setForm({...form,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button disabled={loading}>Pesquisar</button><button type="button" disabled={loading} onClick={()=>setQuery(q=>({...q,page:1}))}>Actualizar consulta</button></form>
|
||||
{loading&&<p role="status">A consultar a origem e o livro financeiro…</p>}{error&&<p role="alert">{error}. A indisponibilidade não significa saldo zero.</p>}{data&&<><p>{data.pagination.total} documento(s) · página {data.pagination.page} de {data.pagination.last_page} · leitura {data.observed_at}.</p><dl className="overview-definitions">{summary.totals.map(t=><div key={t.currency}><dt>Saldo conhecido apenas desta página</dt><dd>{t.value} {t.currency}</dd></div>)}<div><dt>Sem reconhecimento nesta página</dt><dd>{summary.unrecognized}</dd></div><div><dt>Saldo indeterminado nesta página</dt><dd>{summary.unknown}</dd></div></dl><>{query.reference_on&&<section><h2>Faixas apenas desta página · referência{data.aging_reference_on}</h2><dl className="overview-definitions">{payableAgeSummary(data).map(t=><div key={t.currency+'|'+t.bucket}><dt>{ageLabels[t.bucket]}</dt><dd>{t.value} {t.currency}</dd></div>)}</dl><p>Saldos indeterminados e documentos sem reconhecimento ficam fora das faixas monetárias; saldos liquidados não representam comprovativo bancário.</p></section>}</><PayablePositionRows data={data} onDue={setSelected}/><div className="actions"><button disabled={loading||data.pagination.page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior</button><button disabled={loading||data.pagination.page>=data.pagination.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte</button><button onClick={exportPage}>Exportar apenas esta página CSV</button></div></>}
|
||||
<p className="help">O número de documentos refere-se ao filtro completo; os saldos resumidos e o CSV referem-se apenas à página. Não se somam moedas diferentes. Cada página é uma leitura actual, sem fotografia global entre páginas ou corte histórico. O vencimento e o atraso permanecem indeterminados quando o livro não os sustenta. Um pagamento preparado não comprova execução bancária.</p></section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React,{useEffect,useMemo,useRef,useState}from'react';
|
||||
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
|
||||
import{personnelDownload}from'./statement-download.js';
|
||||
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
|
||||
export const personnelKinds={contract:'Contratos',day:'Planos e assiduidade',entitlement:'Condições de férias',leave:'Pedidos e gozo de férias',report:'Conferência do período'};
|
||||
const labels={prepared:'Preparado — por rever',approved:'Revisto e aprovado',refused:'Recusado',active:'Activo',terminated:'Terminado',cancelled:'Cancelado',reserved:'Reservado',taken:'Gozado'};
|
||||
const duration=m=>m===null||m===undefined?'Desconhecido':`${Math.floor(m/60)}h ${m%60}min`,clock=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0'),minutes=v=>Number(v.slice(0,2))*60+Number(v.slice(3));
|
||||
export function checkedPersonnel(data,q){if(data.policy!=='SGF-HR-PERSONNEL-QA-v1'||data.kind!==q.kind||data.synthetic_only!==true||data.salary_effect!==false||!Array.isArray(data.records?.data)||data.records.current_page!==q.page||data.records.data.length>Number(q.per_page)||data.records.data.some(r=>r.kind!==q.kind||q.worker_key&&r.data.input.worker_key!==q.worker_key||q.record_key&&r.record_key!==q.record_key))throw Error('Resposta de outro âmbito, pessoa ou página. Consulte novamente.');return data;}
|
||||
export function PersonnelSourcePicker({kind,workerKey,api,context,value,onChange,disabled,optional=false}){
|
||||
const[query,setQuery]=useState({page:1,per_page:25,search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');useEffect(()=>{let active=true;setData(null);setError('');if(kind!=='worker'&&!workerKey)return;const q={...query,kind:kind==='worker'?'worker':kind,...(workerKey?{worker_key:workerKey}:{})},path=kind==='worker'?'/api/remunerations':'/api/hr-personnel';api(path+'?'+qs(q)).then(r=>{if(active)setData(kind==='worker'?r:checkedPersonnel(r,q));}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[query,workerKey,context.token,kind]);const records=data?.records.data??[];
|
||||
return <fieldset disabled={disabled}><legend>{kind==='worker'?'Colaborador fictício revisto':personnelKinds[kind]+' — fonte revista'}</legend><label>Pesquisar fonte<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>setQuery({page:1,per_page:25,search})}>Pesquisar {kind==='worker'?'colaborador':personnelKinds[kind]}</button>{error&&<p role="alert">{error}</p>}<label>Escolha explícita<select required={!optional} value={value} onChange={e=>{const r=records.find(r=>r.record_key===e.target.value);onChange(e.target.value,r?.effective??r);}}><option value="">{optional?'Sem ligação contratual escolhida':'Escolher…'}</option>{value&&!records.some(r=>r.record_key===value)&&<option value={value}>Fonte escolhida anteriormente</option>}{records.filter(r=>r.effective&&r.effective.data.status!=='cancelled'&&(kind!=='worker'||r.effective.data.status==='active')).map(r=><option key={r.record_key} value={r.record_key}>{r.effective.data.input.code??r.effective.data.input.reference??r.effective.data.input.date??(r.effective.data.input.valid_from+' a '+r.effective.data.input.valid_to)} · v{r.effective.version}</option>)}</select></label>{data&&<><button type="button" disabled={disabled||query.page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior — fontes</button><span>Página{data.records.current_page}/{data.records.last_page}</span><button type="button" disabled={disabled||query.page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte — fontes</button></>}</fieldset>;
|
||||
}
|
||||
function Intervals({label,value,onChange,disabled,nullable=false}){return <fieldset disabled={disabled}><legend>{label}</legend>{nullable&&<label><input type="checkbox" checked={value===null} onChange={e=>onChange(e.target.checked?null:[])}/>Informação ainda desconhecida</label>}{value!==null&&<>{value.map((r,i)=><div className="form-grid" key={i}><label>Desde<input required type="time" value={clock(r.from)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,from:minutes(e.target.value)}:x))}/></label><label>Até<input required type={r.to===1440?'text':'time'} value={clock(r.to)} onChange={e=>onChange(value.map((x,j)=>j===i?{...x,to:minutes(e.target.value)}:x))}/></label><button type="button" onClick={()=>onChange(value.filter((_,j)=>i!==j))}>Remover intervalo</button></div>)}<button type="button" disabled={disabled||value.length>=8} onClick={()=>onChange([...value,{from:480,to:720}])}>Adicionar intervalo</button>{value.length===0&&<p>Conjunto explicitamente vazio. Não equivale a informação desconhecida.</p>}</>}</fieldset>;}
|
||||
export function PersonnelResult({kind,data}){const i=data.input,r=data.result;return <section><h3>{personnelKinds[kind]} · {labels[data.status]}</h3><p>{data.dependencies.worker.code} · {i.reference??i.date??i.period??(i.valid_from+' a '+i.valid_to)}.</p>{kind==='contract'&&<dl className="overview-definitions"><dt>Modalidade interna</dt><dd>{{fixed:'Prazo explícito',open:'Sem termo declarado',unknown_end:'Fim desconhecido'}[i.mode]}</dd><dt>Vigência</dt><dd>{i.valid_from} a {i.valid_to??'Sem fim confirmado'}</dd><dt>Bruto mensal declarado</dt><dd>{i.gross} MZN</dd><dt>Dependentes revistos</dt><dd>{r.dependent_records}</dd></dl>}{kind==='day'&&<dl className="overview-definitions"><dt>Previsto</dt><dd>{duration(r.planned_minutes)}</dd><dt>Declarado</dt><dd>{duration(r.declared_minutes)}</dd><dt>Férias conferidas</dt><dd>{duration(r.leave_minutes)}</dd><dt>Por conferir</dt><dd>{duration(r.unaccounted_minutes)}</dd><dt>Fora do plano, sem autorização de horas extra</dt><dd>{duration(r.outside_plan_minutes)}</dd></dl>}{['entitlement','leave'].includes(kind)&&<><p>{kind==='leave'?'Pedido: '+duration(r.minutes)+'. ':''}Saldo do exercício{duration((r.balance_after??r).available_minutes)}, reservado{duration((r.balance_after??r).reserved_minutes)}, gozado{duration((r.balance_after??r).taken_minutes)}.</p><p>Minutos de orçamento fictício; sem direito anual legal presumido.</p></>}{kind==='report'&&<><p>{r.known_days} dias com registos; {r.known_conferred_days} conferidos; {r.other_calendar_days_unknown} outros dias sem cobertura comprovada. Mês completo: não demonstrado.</p><div className="table-scroll"><table><thead><tr><th>Dia / versão</th><th>Previsto</th><th>Declarado</th><th>Férias</th><th>Por conferir</th></tr></thead><tbody>{r.days.map(d=><tr key={d.record_key}><td>{d.calculation.date} · v{d.version}</td><td>{duration(d.calculation.planned_minutes)}</td><td>{duration(d.calculation.declared_minutes)}</td><td>{duration(d.calculation.leave_minutes)}</td><td>{duration(d.calculation.unaccounted_minutes)}</td></tr>)}</tbody></table></div>{r.leave_balances.map(b=><p key={b.entitlement_key}>Condição v{b.entitlement_version}: disponível{duration(b.available_minutes)}, reservado{duration(b.reserved_minutes)}, gozado{duration(b.taken_minutes)}.</p>)}{r.payroll.map(p=><p key={p.record_key}>Processamento v{p.version}: {p.contract_link_proven?'Tem contrato escolhido na origem.':'Sem ligação contratual histórica comprovada.'} O processamento não foi recalculado.</p>)}</>}<p>Evidência: {i.evidence}. Sem salário, dívida ou pagamento automático.</p></section>;}
|
||||
export default function PersonnelWorkspace({api,context,initialKind='contract'}){
|
||||
const[worker,setWorker]=useState(null),[query,setQuery]=useState({kind:initialKind,page:1,per_page:25}),[filters,setFilters]=useState({search:'',period:'',state:''}),[data,setData]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);const op=useMemo(()=>new WorkshopStockOperation(api),[context.token]),generation=useRef(context.token),mounted=useRef(true);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||op.uncertain,protect=locked||Boolean(form);
|
||||
useEffect(()=>{if(!query.worker_key)return;let active=true;setBusy(true);setError('');setData(null);api('/api/hr-personnel?'+qs(query)).then(r=>{if(active)setData(checkedPersonnel(r,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[context.token,query]);
|
||||
function start(operation='establish',review=false,newRecord=false){const row=newRecord?null:selected,kind=query.kind,old=row?.effective?.data.input??row?.data.input;const defaults={contract:{reference:'QA-',mode:'fixed',valid_from:'',valid_to:'',gross:''},day:{contract_key:'',date:'',reference_on:'',planned:null,actual:null,leave_conferred:false},entitlement:{contract_key:'',valid_from:'',valid_to:'',minutes:null},leave:{contract_key:'',entitlement_key:'',day_key:'',date:'',reference_on:'',intervals:[]},report:{period:''}}[kind];setPreview(null);setError('');setForm({kind,record_key:row?.record_key??crypto.randomUUID(),version:row?.version??0,action:review?'review':'prepare',reason:'',data:review?{decision:''}:{...defaults,...old,worker_key:query.worker_key,operation,evidence:''}});}
|
||||
const change=(k,v)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[k]:v}}));};
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const body={...form,data:{...form.data}};if(body.kind==='contract'&&body.action==='prepare'&&body.data.mode!=='fixed')body.data.valid_to=null;const key=crypto.randomUUID(),out=await api('/api/hr-personnel/preview','POST',body,undefined,key);setPreview({...out,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await op.retry():await op.send('/api/hr-personnel/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice(`v${out.version} · ${labels[out.state]}${out.replayed?' · recibo recuperado':''}`);setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!op.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function download(row){const token=context.token;setBusy(true);setError('');try{const out=await personnelDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-rh-interno-'+row.kind+'-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
|
||||
const field=(k,label,type='text',extra={})=><label key={k}>{label}<input required type={type} value={form.data[k]??''} onChange={e=>change(k,e.target.value)} {...extra}/></label>,source=(kind,key)=> <PersonnelSourcePicker kind={kind} workerKey={query.worker_key} context={context} api={api} value={form.data[key]} onChange={v=>change(key,v)} disabled={locked||Boolean(preview)||form.version>0}/>;
|
||||
return <section className="panel" data-sgf-operation-uncertain={protect?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Contratos, assiduidade e férias QA</h1><p>Fontes e decisões individuais, com revisão distinta. Registos internos fictícios; sem calendário laboral legal presumido nem alteração automática de remunerações.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<PersonnelSourcePicker kind="worker" api={api} context={context} value={query.worker_key??''} onChange={(key,row)=>{setWorker(row??null);setSelected(null);setData(null);setQuery({kind:query.kind,page:1,per_page:25,...(key?{worker_key:key}:{})});}} disabled={protect}/>{worker&&<p>Ficha de {worker.data.input.code}, cadastro revisto v{worker.version}. Consultas abaixo mantêm esta pessoa e o contexto.</p>}<nav aria-label="Funções de pessoal">{Object.entries(personnelKinds).map(([kind,label])=><button key={kind} disabled={protect||!query.worker_key} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,worker_key:query.worker_key,page:1,per_page:25});}}>{label}</button>)}</nav>{!query.worker_key&&<p>Escolha explicitamente um colaborador revisto para consultar ou preparar.</p>}
|
||||
{query.worker_key&&<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,worker_key:q.worker_key,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protect}><label>Pesquisar referência/código<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{['day','leave','report'].includes(query.kind)&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{['prepared','approved','refused'].map(s=><option key={s} value={s}>{labels[s]}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>}
|
||||
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo registo — {personnelKinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} registo/versão(s).</p><div className="table-scroll"><table><thead><tr><th>Referência/dia/período</th><th>Versão e decisão</th><th>Estado revisto actual</th><th>Autor e fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.reference??r.data.input.date??r.data.input.period??(r.data.input.valid_from+' a '+r.data.input.valid_to)}</td><td>v{r.version} · {labels[r.state]}<small>{r.data.input.operation}</small></td><td>{r.effective?labels[r.effective.data.status]:'Sem aprovação'}{r.effective&&<small>v{r.effective.version}</small>}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protect} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protect||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protect||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protect} onClick={()=>{setSelected(null);setQuery({kind:query.kind,worker_key:query.worker_key,page:1,per_page:25});}}>Voltar à lista actual</button>}</>}
|
||||
{selected&&<section><h2>Registo escolhido · v{selected.version} · {labels[selected.state]}</h2><PersonnelResult kind={selected.kind} data={selected.data}/><p>Antes v{selected.impact.before.version}, {labels[selected.impact.before.state]??'Sem registo'}; depois v{selected.impact.after.version}, {labels[selected.state]}.</p><button disabled={protect} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protect} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{selected.effective&&<button disabled={protect} onClick={()=>start('cancel')}>Preparar cancelamento</button>}{selected.kind==='contract'&&selected.effective&&<button disabled={protect} onClick={()=>start('terminate')}>Preparar terminação</button>}{selected.kind==='leave'&&selected.effective?.data.status==='reserved'&&<button disabled={protect} onClick={()=>start('take')}>Declarar gozo sem automatismo</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protect} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&<button disabled={protect} onClick={()=>download(selected)}>Descarregar versão conservada</button>}</section>}
|
||||
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='review'?'Revisão distinta':'Preparação individual'} · {personnelKinds[form.kind]} · v{form.version}</h2>{form.action==='review'?<label>Decisão<select required value={form.data.decision} onChange={e=>change('decision',e.target.value)}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label>:<><p>Operação: {form.data.operation}. Pessoa e fontes são revalidadas na confirmação.</p><fieldset disabled={['cancel','take'].includes(form.data.operation)}>{form.kind==='contract'?<>{field('reference','Referência interna QA','text',{readOnly:form.version>0,pattern:'QA-[A-Z0-9-]{2,60}'})}<label>Modalidade interna<select value={form.data.mode} onChange={e=>change('mode',e.target.value)}><option value="fixed">Prazo explícito</option><option value="open">Sem termo declarado</option><option value="unknown_end">Fim desconhecido — vigência incompleta</option></select></label>{field('valid_from','Início','date',{readOnly:form.version>0})}{field('valid_to','Fim explícito','date',{required:form.data.mode==='fixed',disabled:form.data.mode!=='fixed'})}{field('gross','Bruto mensal MZN','text',{pattern:'[0-9]{1,9}\\.[0-9]{2}',readOnly:form.data.operation==='terminate'})}</>:form.kind==='report'?field('period','Período da conferência','month',{readOnly:form.version>0}):<>{source('contract','contract_key')}{form.kind==='entitlement'?<>{field('valid_from','Vigência desde','date')}{field('valid_to','Vigência até','date')}<label><input type="checkbox" checked={form.data.minutes===null} onChange={e=>change('minutes',e.target.checked?null:0)}/>Saldo inicial explicitamente desconhecido</label><label>Orçamento QA em minutos<input type="number" min="0" max="527040" required={form.data.minutes!==null} disabled={form.data.minutes===null} value={form.data.minutes??''} onChange={e=>change('minutes',Number(e.target.value))}/></label></>:<>{form.kind==='leave'&&<>{source('entitlement','entitlement_key')}{source('day','day_key')}</>}{field('date','Dia local','date',{readOnly:form.version>0})}</>}</>}</fieldset>{['day','leave'].includes(form.kind)&&field('reference_on','Referência da declaração (não futura)','date')}{form.kind==='day'&&<><Intervals label="Intervalos previstos" value={form.data.planned} nullable onChange={v=>change('planned',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><Intervals label="Intervalos declarados" value={form.data.actual} nullable onChange={v=>change('actual',v)} disabled={locked||Boolean(preview)||form.data.operation==='cancel'}/><label><input type="checkbox" checked={form.data.leave_conferred} disabled={form.data.operation==='cancel'} onChange={e=>change('leave_conferred',e.target.checked)}/>Conferir explicitamente férias efectivas deste dia</label></>}{form.kind==='leave'&&<Intervals label="Intervalos do pedido" value={form.data.intervals} onChange={v=>change('intervals',v)} disabled={locked||Boolean(preview)||['cancel','take'].includes(form.data.operation)}/>}<label>Referência da evidência<textarea required minLength={10} maxLength={1000} value={form.data.evidence} onChange={e=>change('evidence',e.target.value)}/></label></>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
|
||||
{preview&&<section><h2>Impacto antes da confirmação</h2><p>Antes v{preview.impact.before.version}, {labels[preview.impact.before.state]??'Sem registo'}; depois v{preview.impact.after.version}, {labels[preview.impact.after.state]}.</p><PersonnelResult kind={form.kind} data={preview.impact.after.proposal}/><p>Fundamento: {form.reason}. Sem alteração salarial, dívida, pagamento ou lançamento.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}{op.uncertain&&<><p role="alert">Resultado incerto. Conserve corpo/chave e não inicie outra decisão.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
|
||||
<h2>Recibo técnico próprio</h2><label>Chave<input disabled={protect} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={protect||!receiptKey} onClick={async()=>{setBusy(true);setError('');try{setReceipt(await api('/api/hr-personnel/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}}>Consultar recibo sem executar</button>{receipt&&<p>v{receipt.result.version} · {labels[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Não foi executada outra operação.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {positionStates,dueStates,exactMoney,checkedPositions,positionsCsv} from './receivable-position-view.js';
|
||||
export function ReceivablePositionRows({data,onOpen}){return <div className="table-scroll"><table><thead><tr><th>Documento / cliente na emissão</th><th>Estado</th><th>Valor de origem</th><th>Redução / afectação líquidas</th><th>Saldo reconhecido</th><th>Vencimento e situação</th><th>Evidências</th></tr></thead><tbody>{data.positions.data.map(r=><tr key={r.id}><td>{r.reference}<small>{r.client??'Cliente por conferir na origem'}</small></td><td>{positionStates[r.state]}</td><td>{exactMoney(r.original)} MZN</td><td>{exactMoney(r.reduced)} / {exactMoney(r.allocated)} MZN</td><td>{r.balance===null?'Ainda não reconhecido':exactMoney(r.balance)+' MZN'}</td><td>{r.due_date??'Data desconhecida'}<small>{dueStates[r.due_state]}</small><small>{r.overdue_days===null?'Dias de atraso indeterminados':r.overdue_days+' dia(s) de atraso na referência'}</small></td><td>Revisão {r.revision}<button onClick={()=>onOpen(r.id)}>Abrir posição e comprovativos</button></td></tr>)}</tbody></table>{data.positions.data.length===0&&<p>Nenhuma posição corresponde aos filtros autorizados.</p>}</div>;}
|
||||
export default function ReceivablePositionsWorkspace({context,api,onOpen}){
|
||||
const [form,setForm]=useState({reference_on:'',search:'',state:'',due_state:'',per_page:'25'}),[query,setQuery]=useState(null),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);const basis=useRef(null);
|
||||
useEffect(()=>{let current=true;setData(null);setError('');if(!query)return()=>{current=false;};setLoading(true);const expected=query.page>1?basis.current:null;api('/api/service-receivables/positions?'+new URLSearchParams(Object.entries(query).filter(([,v])=>v!==''))).then(v=>{const result=checkedPositions(v,query,expected);if(current){setData(result);basis.current=JSON.stringify(result.basis);}}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setLoading(false);});return()=>{current=false;};},[context.token,query]);
|
||||
const load=(value,page=1)=>{if(!/^\d{4}-\d{2}-\d{2}$/.test(value.reference_on)){setError('Escolha uma data explícita para conferir vencimentos.');return;}if(page===1)basis.current=null;setQuery({...value,page});};
|
||||
const download=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+positionsCsv(data,query)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-posicoes-clientes-pagina-'+data.positions.current_page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
return <section className="panel"><p className="eyebrow">FINANCEIRO</p><h1>Posições de clientes</h1><p>Saldos actuais do livro de recebíveis. A data escolhida classifica apenas o vencimento; não reconstitui saldos históricos. Propostas por confirmar e recusadas não entram no saldo reconhecido.</p><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();load(form);}}><div className="form-grid"><label>Referência para vencimentos<input type="date" required value={form.reference_on} onChange={e=>setForm({...form,reference_on:e.target.value})}/></label><label>Documento ou cliente na emissão<input type="search" maxLength={100} value={form.search} onChange={e=>setForm({...form,search:e.target.value})}/></label><label>Estado<select value={form.state} onChange={e=>setForm({...form,state:e.target.value})}><option value="">Todos</option>{Object.entries(positionStates).map(([v,t])=><option key={v} value={v}>{t}</option>)}</select></label><label>Vencimento<select value={form.due_state} onChange={e=>setForm({...form,due_state:e.target.value})}><option value="">Todos</option>{Object.entries(dueStates).map(([v,t])=><option key={v} value={v}>{t}</option>)}</select></label><label>Por página<select value={form.per_page} onChange={e=>setForm({...form,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button disabled={loading}>Consultar posições</button></form>
|
||||
{loading&&<p role="status">A consultar a página e os efeitos confirmados…</p>}{error&&<div role="alert"><p>{error}</p><p>Uma consulta indisponível não significa saldo zero.</p>{query&&<button onClick={()=>load(query)}>Actualizar a primeira página</button>}</div>}{data&&<><dl className="overview-definitions"><div><dt>Saldo reconhecido do filtro completo</dt><dd>{exactMoney(data.totals.recognized_balance)} MZN</dd></div><div><dt>Posições reconhecidas</dt><dd>{data.totals.recognized_positions}</dd></div><div><dt>Propostas sem reconhecimento</dt><dd>{data.totals.unrecognized_proposals}</dd></div><div><dt>Vencimento desconhecido com saldo</dt><dd>{data.totals.unknown_due_positions}</dd></div></dl><p>{data.positions.total} registo(s) · página {data.positions.current_page} de {data.positions.last_page} · leitura {data.observed_at}. Referência para vencimentos: {data.reference_on}.</p><ReceivablePositionRows data={data} onOpen={onOpen}/><div className="actions"><button disabled={loading||data.positions.current_page<=1} onClick={()=>load(query,data.positions.current_page-1)}>Anterior</button><button disabled={loading||data.positions.current_page>=data.positions.last_page} onClick={()=>load(query,data.positions.current_page+1)}>Seguinte</button><button onClick={download}>Exportar apenas esta página CSV</button></div></>}
|
||||
<p className="help">Documento, recebimento confirmado, afectação e saldo são factos distintos. Esta lista não cria cobranças, juros, dívida ou lançamentos, nem conclui que uma posição liquidada corresponde a dinheiro recebido. Abrir uma posição conserva a escolha explícita e revalida a autoridade no percurso original.</p></section>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React,{useEffect,useMemo,useRef,useState}from'react';
|
||||
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
|
||||
const PersonnelSourcePicker=React.lazy(()=>import('./PersonnelWorkspace.jsx').then(m=>({default:m.PersonnelSourcePicker})));
|
||||
import{remunerationDownload}from'./statement-download.js';
|
||||
const qs=q=>new URLSearchParams(Object.entries(q).filter(([,v])=>v!==''&&v!==null)).toString();
|
||||
const states={prepared:'Preparado — sem aprovação',approved:'Revisto e aprovado',refused:'Recusado'},kinds={worker:'Cadastro fictício',rules:'Regras artificiais',payroll:'Processamento interno'};
|
||||
export const remunerationMoney=c=>c===null||c===undefined?'Indeterminado':new Intl.NumberFormat('pt-MZ',{minimumFractionDigits:2,maximumFractionDigits:2}).format(Number(c)/100)+' MZN';
|
||||
export function checkedRemunerations(data,q){if(data.policy!=='SGF-REMUNERATIONS-QA-v1'||data.synthetic_only!==true||data.legal_payroll_validated!==false||data.kind!==q.kind||data.records?.current_page!==q.page||!Array.isArray(data.records?.data)||data.records.data.length>Number(q.per_page)||data.records.data.some(r=>r.kind!==q.kind||(q.record_key&&r.record_key!==q.record_key)))throw Error('Resposta de outro âmbito/página. Consulte novamente.');return data;}
|
||||
function SourcePicker({kind,api,context,value,onChange,disabled}){
|
||||
const[query,setQuery]=useState({kind,page:1,per_page:25}),[search,setSearch]=useState(''),[data,setData]=useState(null),[error,setError]=useState('');
|
||||
useEffect(()=>{let active=true;setData(null);api('/api/remunerations?'+qs(query)).then(d=>{if(active)setData(checkedRemunerations(d,query));}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,query]);
|
||||
return <fieldset disabled={disabled}><legend>{kind==='worker'?'Escolher colaborador fictício revisto':'Escolher regras revistas'}</legend><label>Pesquisar fonte<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>setQuery(q=>({...q,search,page:1}))}>Pesquisar {kinds[kind]}</button>{error&&<p role="alert">{error}</p>}<label>Fonte explícita<select required value={value} onChange={e=>onChange(e.target.value)}><option value="">Escolher…</option>{value&&!data?.records.data.some(r=>r.record_key===value)&&<option value={value}>Escolha anterior: {value}</option>}{data?.records.data.filter(r=>r.effective&&(kind!=='worker'||r.effective.data.status==='active')).map(r=><option key={r.record_key} value={r.record_key}>{r.effective.data.input.code??r.effective.data.input.label} · v{r.effective.version}</option>)}</select></label>{data&&<><button type="button" disabled={disabled||query.page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior — {kinds[kind]}</button><span>Página{data.records.current_page}/{data.records.last_page}</span><button type="button" disabled={disabled||query.page>=data.records.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte — {kinds[kind]}</button></>}</fieldset>;
|
||||
}
|
||||
function Calculation({value}){if(!value)return null;return <section><h3>Cálculo artificial explicável</h3><p>Bruto{remunerationMoney(value.gross_cents)} · descontos{remunerationMoney(value.deductions_cents)} · líquido interno{remunerationMoney(value.net_cents)} · custo interno{remunerationMoney(value.employer_cost_cents)}.</p><p>{value.complete?'Parâmetros do exercício completos.':'Parâmetros em falta: aprovação completa bloqueada.'} Sem validação legal de IRPS/INSS.</p><div className="table-scroll"><table><thead><tr><th>Rubrica QA</th><th>Percentagem artificial</th><th>Valor documental</th><th>Exacto / diferença em centavos</th></tr></thead><tbody>{Object.entries(value.rubrics).map(([k,r])=><tr key={k}><td>{{worker_rate:'Desconto do trabalhador',employer_rate:'Encargo patronal',retention_rate:'Retenção artificial'}[k]}</td><td>{r.rate??'Em falta'}</td><td>{remunerationMoney(r.cents)}</td><td>{r.exact??'Indeterminado'} / {r.difference??'Indeterminado'}</td></tr>)}</tbody></table></div></section>;}
|
||||
export default function RemunerationsWorkspace({api,context}){
|
||||
const[query,setQuery]=useState({kind:'worker',page:1,per_page:25}),[filters,setFilters]=useState({search:'',period:'',state:''}),[data,setData]=useState(null),[selected,setSelected]=useState(null),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState(''),[receiptKey,setReceiptKey]=useState(''),[receipt,setReceipt]=useState(null);
|
||||
const operation=useMemo(()=>new WorkshopStockOperation(api),[context.token]),mounted=useRef(true),generation=useRef(context.token);generation.current=context.token;useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[context.token]);const locked=busy||operation.uncertain,protectedForm=locked||Boolean(form);
|
||||
useEffect(()=>{let active=true;setBusy(true);setData(null);setError('');api('/api/remunerations?'+qs(query)).then(d=>{if(active)setData(checkedRemunerations(d,query));}).catch(e=>{if(active)setError(e.message);}).finally(()=>{if(active)setBusy(false);});return()=>{active=false;};},[query,context.token]);
|
||||
function start(operationName='establish',review=false,newRecord=false){const row=newRecord?null:selected,source=row?.effective?.data.input??row?.data.input,kind=query.kind;const defaults=kind==='worker'?{code:'QA-',status:'active'}:kind==='rules'?{label:'',valid_from:'',valid_to:'',worker_rate:'',employer_rate:'',retention_rate:'',source:''}:{worker_key:'',rules_key:'',period:'',gross:''};setPreview(null);setError('');setForm({kind,action:review?'review':'prepare',record_key:row?.record_key??crypto.randomUUID(),version:row?.version??0,reason:'',data:review?{decision:''}:{...(source??defaults),operation:operationName}});}
|
||||
const change=(key,value)=>{setPreview(null);setForm(f=>({...f,data:{...f.data,[key]:value}}));};
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input={...form,data:{...form.data}};if(!input.data.contract_key)delete input.data.contract_key;if(input.kind==='rules'&&input.action==='prepare')for(const k of['worker_rate','employer_rate','retention_rate'])if(input.data[k]==='')input.data[k]=null;const key=crypto.randomUUID(),p=await api('/api/remunerations/preview','POST',input,undefined,key);setPreview({...p,key});}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const out=retry?await operation.retry():await operation.send('/api/remunerations/confirm',{preview:preview.preview},preview.key);setReceiptKey(preview.key);setNotice('Decisão v'+out.version+' · '+states[out.state]+(out.replayed?' · recibo recuperado':''));setSelected(null);setForm(null);setPreview(null);setQuery(q=>({...q,page:1}));}catch(e){setError(e.message);if(!operation.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function download(row){const token=context.token;setBusy(true);setError('');try{const out=await remunerationDownload({id:row.id,token,sha256:row.data.artifact_sha256,isCurrent:()=>mounted.current&&generation.current===token});const url=URL.createObjectURL(new Blob([out.bytes],{type:'text/csv;charset=utf-8'})),a=document.createElement('a');a.href=url;a.download='sgf-remuneracao-interna-QA-v'+row.version+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){if(mounted.current&&generation.current===token)setError(e.message);}finally{if(mounted.current&&generation.current===token)setBusy(false);}}
|
||||
async function recover(){setBusy(true);setError('');try{setReceipt(await api('/api/remunerations/receipts/'+encodeURIComponent(receiptKey)));}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
const textInput=(key,label,type='text',extra={})=><label key={key}>{label}<input required type={type} value={form.data[key]??''} onChange={e=>change(key,e.target.value)} {...extra}/></label>;
|
||||
return <section className="panel" data-sgf-operation-uncertain={protectedForm?'true':undefined}><p className="eyebrow">RECURSOS HUMANOS</p><h1>Remunerações internas QA</h1><p>Área autónoma, com acessos próprios. Cadastro e regras fictícios, processamento e revisão distinta. Sem salário/dívida/pagamento/lançamento, recibo legal ou submissão. Contratos, assiduidade, férias e conformidade legal continuam pendentes.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
<nav aria-label="Funções de Recursos Humanos">{Object.entries(kinds).map(([kind,label])=><button key={kind} disabled={protectedForm} aria-current={query.kind===kind?'page':undefined} onClick={()=>{setSelected(null);setFilters({search:'',period:'',state:''});setQuery({kind,page:1,per_page:25});}}>{label}</button>)}</nav>
|
||||
<form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setSelected(null);setQuery(q=>({kind:q.kind,per_page:q.per_page,...filters,page:1}));}}><fieldset disabled={protectedForm}><label>Pesquisa por código / regras<input value={filters.search} onChange={e=>setFilters(f=>({...f,search:e.target.value}))}/></label>{query.kind==='payroll'&&<label>Período<input type="month" value={filters.period} onChange={e=>setFilters(f=>({...f,period:e.target.value}))}/></label>}<label>Estado<select value={filters.state} onChange={e=>setFilters(f=>({...f,state:e.target.value}))}><option value="">Todos</option>{Object.entries(states).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>{setSelected(null);setQuery(q=>({...q,per_page:Number(e.target.value),page:1}));}}><option value="25">25</option><option value="100">100</option></select></label><button>Consultar</button></fieldset></form>
|
||||
{data&&<>{data.capabilities.prepare&&!query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);start('establish',false,true);}}>Novo: {kinds[query.kind]}</button>}<p>Página{data.records.current_page}/{data.records.last_page} · {data.records.total} versão/registo(s).</p><div className="table-scroll"><table><thead><tr><th>Registo / período</th><th>Versão e decisão</th><th>Resultado revisto actual</th><th>Autor / fundamento</th><th>Escolha</th></tr></thead><tbody>{data.records.data.map(r=><tr key={r.id}><td>{r.data.input.code??r.data.input.label??r.data.dependencies.worker?.data.input.code}<small>{r.data.input.period??''} · {r.record_key}</small></td><td>v{r.version} · {states[r.state]} · {r.data.input.operation}</td><td>{r.effective?<>v{r.effective.version} · {r.effective.data.status}{r.effective.data.calculation&&<small>Líquido interno{remunerationMoney(r.effective.data.calculation.net_cents)}</small>}</>:'Sem aprovação'}</td><td>QA{r.actor_id} · {r.created_at}<small>{r.reason}</small></td><td><button disabled={protectedForm} onClick={()=>{setSelected(r);setReceipt(null);}}>Escolher este registo</button></td></tr>)}</tbody></table></div>{!data.records.data.length&&<p>Sem registos neste âmbito/filtro.</p>}<button disabled={protectedForm||query.page<=1} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page-1}));}}>Anterior</button><button disabled={protectedForm||query.page>=data.records.last_page} onClick={()=>{setSelected(null);setQuery(q=>({...q,page:q.page+1}));}}>Seguinte</button>{query.record_key&&<button disabled={protectedForm} onClick={()=>{setSelected(null);setQuery({kind:query.kind,page:1,per_page:25});}}>Voltar à lista actual</button>}{query.kind==='payroll'&&!query.record_key&&<p>Somente aprovados activos desta página: {data.page_totals.approved_active_on_page}; bruto{remunerationMoney(data.page_totals.gross_cents)}, líquido{remunerationMoney(data.page_totals.net_cents)}, custo{remunerationMoney(data.page_totals.employer_cost_cents)}. Não são totais do universo nem salários pagos.</p>}</>}
|
||||
{selected&&<section><h2>Registo escolhido · v{selected.version}</h2><pre>{JSON.stringify(selected.data.input,null,2)}</pre><Calculation value={selected.data.calculation}/><p>Estado anterior: v{selected.impact.before.version} · {selected.impact.before.state??'Sem registo'}. Estado posterior: v{selected.impact.after.version} · {states[selected.state]}.</p><button disabled={protectedForm} onClick={()=>{setSelected(null);setQuery(q=>({...q,record_key:selected.record_key,page:1}));}}>Abrir histórico deste registo</button>{!query.record_key&&data.capabilities.prepare&&selected.effective?.data.status!=='cancelled'&&<><button disabled={protectedForm} onClick={()=>start(selected.effective?'amend':'establish')}>Preparar {selected.effective?'correcção':'nova versão'}</button>{query.kind==='payroll'&&selected.effective&&<button disabled={protectedForm} onClick={()=>start('cancel')}>Preparar cancelamento interno</button>}</>}{!query.record_key&&data.capabilities.review&&selected.state==='prepared'&&<button disabled={protectedForm} onClick={()=>start('',true)}>Rever preparação alheia</button>}{selected.state==='approved'&&selected.kind==='payroll'&&<button disabled={protectedForm} onClick={()=>download(selected)}>Descarregar mapa conservado desta versão</button>}</section>}
|
||||
{form&&<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><h2>{form.action==='review'?'Revisão distinta':'Preparação individual'} · {kinds[form.kind]} · v{form.version}</h2>{form.action==='review'?<label>Decisão<select required value={form.data.decision} onChange={e=>change('decision',e.target.value)}><option value="">Escolher…</option><option value="approved">Aprovar</option><option value="refused">Recusar</option></select></label>:<><p>Operação{form.data.operation}. A versão e as fontes serão conferidas outra vez na confirmação.</p>{form.kind==='worker'?<>{textInput('code','Código pseudónimo QA','text',{pattern:'QA-[A-Z0-9-]{2,60}',readOnly:form.version>0})}<label>Estado<select required value={form.data.status} onChange={e=>change('status',e.target.value)}><option value="active">Activo</option><option value="suspended">Suspenso</option></select></label></>:form.kind==='rules'?<>{textInput('label','Designação das regras')}{textInput('valid_from','Vigência desde','date')}{textInput('valid_to','Vigência até','date')}{['worker_rate','employer_rate','retention_rate'].map((key,i)=>textInput(key,['Desconto trabalhador % artificial','Encargo patronal % artificial','Retenção % artificial'][i],'number',{required:false,min:0,max:100,step:'0.0001'}))}{textInput('source','Fonte do exercício QA (não legal)')}</>:<><SourcePicker kind="worker" api={api} context={context} value={form.data.worker_key} onChange={v=>change('worker_key',v)} disabled={locked||form.version>0}/><SourcePicker kind="rules" api={api} context={context} value={form.data.rules_key} onChange={v=>change('rules_key',v)} disabled={locked||form.data.operation==='cancel'}/>{context.hr_personnel_enabled&&<React.Suspense fallback={<p>A abrir contratos…</p>}><PersonnelSourcePicker kind="contract" workerKey={form.data.worker_key} api={api} context={context} optional value={form.data.contract_key??''} onChange={key=>change('contract_key',key)} disabled={locked||form.data.operation==='cancel'}/><p>Ligação opcional explícita. Introduza o bruto conforme o contrato revisto; não recalcula versões anteriores.</p></React.Suspense>}{textInput('period','Período artificial','month',{readOnly:form.version>0})}{textInput('gross','Bruto MZN (duas casas decimais)','text',{pattern:'[0-9]{1,9}\\.[0-9]{2}',readOnly:form.data.operation==='cancel'})}</>}</>}<label>Fundamento individual<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm(f=>({...f,reason:e.target.value}))}/></label><button>Conferir impacto</button></fieldset><button type="button" disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar formulário e prévia</button></form>}
|
||||
{preview&&<section><h2>Impacto antes da confirmação</h2><p>Antes v{preview.impact.before.version}, {preview.impact.before.state??'Sem registo'}; depois v{preview.impact.after.version}, {states[preview.impact.after.state]} · {preview.impact.after.proposal.status}.</p><pre>{JSON.stringify(preview.impact.after.proposal.input,null,2)}</pre><Calculation value={preview.impact.after.proposal.calculation}/><p>Fundamento: {form.reason}. Sem pagamento/dívida/lançamentos ou cálculo legal.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão individual</button><p>Chave própria: <code>{preview.key}</code></p></section>}{operation.uncertain&&<><p role="alert">Resultado incerto. Conserve esta decisão e chave.</p><button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button></>}
|
||||
<h2>Recibo técnico próprio</h2><label>Chave<input disabled={protectedForm} value={receiptKey} onChange={e=>setReceiptKey(e.target.value)}/></label><button disabled={protectedForm||!receiptKey} onClick={recover}>Consultar recibo sem executar</button>{receipt&&<p>v{receipt.result.version} · {states[receipt.result.state]} · {receipt.created_at} · {receipt.reason}. Recibo de decisão, sem pagamento.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import FiscalCoverage from './FiscalCoverage.jsx';
|
||||
|
||||
const fieldLabels = {currency:'Moeda',treatment:'Tratamento fiscal',price_mode:'Modo de preço',service_id:'Serviço',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até',rate_percent:'Taxa de IVA',profile_proposal_id:'Proposta do perfil',draft_id:'Rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data de referência',reference_basis:'Fundamento da referência',period_start:'Início do período',period_end:'Fim do período',next_billing:'Próxima facturação prevista',event:'Acontecimento',event_date:'Data do acontecimento'};
|
||||
const valueLabels = {domestic_normal:'Operação interna · regime normal',added:'Preço sem IVA · imposto acrescido',included:'Imposto incluído',pending:'Por determinar',invoice_in_time:'Emissão dentro do prazo fundamentado',invoice_deadline:'Termo do prazo de emissão fundamentado'};
|
||||
@@ -30,8 +31,9 @@ export default function ServiceFiscalWorkspace({ api, context, can, perform, bus
|
||||
const [listing,setListing]=useState(null), [error,setError]=useState(''), [kind,setKind]=useState('profile');
|
||||
const [form,setForm]=useState({identifier:'',previous_id:'',service_id:'',service_version:'',valid_from:'',valid_until:'',conditions:'',evidence:'',profile_proposal_id:'',draft_id:'',draft_version:'',preparation_version:'',reference_date:'',reference_basis:'',period_start:'',period_end:'',next_billing:'',event:'pending',event_date:''});
|
||||
const [reasons,setReasons]=useState({});
|
||||
async function load() { setListing(await api('/api/service-fiscal')); }
|
||||
useEffect(()=>{setListing(null);setError(''); if(context.unit) load().catch(e=>setError(e.message));},[context.token]);
|
||||
const generation=useRef(0);
|
||||
async function load() { const current=++generation.current;try{const result=await api('/api/service-fiscal');if(current===generation.current)setListing(result);}catch(e){if(current===generation.current)throw e;} }
|
||||
useEffect(()=>{setListing(null);setError(''); if(context.unit) load().catch(e=>setError(e.message));return()=>{generation.current++;};},[context.token]);
|
||||
if (!context.unit) return <section className="panel"><h1>Enquadramento fiscal do serviço</h1><p>Seleccione uma unidade autorizada.</p></section>;
|
||||
const fields=kind==='profile' ? {service_id:'ID do serviço existente',service_version:'Versão do serviço',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',conditions:'Condições de aplicabilidade',evidence:'Referências de validação e fontes'} : {profile_proposal_id:'ID da proposta exacta do perfil',draft_id:'ID do rascunho',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',reference_date:'Data explícita para verificar vigência',reference_basis:'Fundamento dessa referência (não presumido)',period_start:'Início do período na preparação',period_end:'Fim do período (opcional)',next_billing:'Próxima facturação prevista (opcional)',event_date:'Data do acontecimento (apenas se determinado)',evidence:'Evidência e fundamento do acontecimento/pêndencia'};
|
||||
const dates=['valid_from','valid_until','reference_date','period_start','period_end','next_billing','event_date'];
|
||||
@@ -41,7 +43,7 @@ export default function ServiceFiscalWorkspace({ api, context, can, perform, bus
|
||||
await api('/api/service-fiscal/proposals','POST',{kind,identifier:form.identifier,...(form.previous_id ? {previous_id:Number(form.previous_id)} : {}),data}); await load();
|
||||
},'Proposta guardada; não activa o perfil.'); }
|
||||
return <section className="fiscal-workspace"><div className="fiscal-page-heading"><p className="eyebrow">ENQUADRAMENTO DO SERVIÇO</p><h1>Perfis e validação fiscal</h1><p>Consulte as condições propostas e acompanhe a validação de cada versão.</p></div><div className="fiscal-notice"><strong>RASCUNHO — SEM VALIDADE FISCAL</strong><span>Aprovar não activa o perfil. Este ecrã não emite facturas; calcular não altera uma decisão temporal validada.</span></div>{error && <p className="alert error" role="alert">{error}</p>}
|
||||
{listing?.migration_pending ? <p>Preparação técnica pendente de migração autorizada; não há perfil activo.</p> : <>
|
||||
<FiscalCoverage coverage={listing?.coverage}/>{listing?.migration_pending ? <p>Preparação técnica pendente de migração autorizada; não há perfil activo.</p> : <>
|
||||
{can('service_fiscal.propose') && <details className="fiscal-new panel"><summary>Preparar nova proposta</summary><form onSubmit={propose}><h2>Preparar proposta para validação</h2><label>Tipo<select value={kind} onChange={e=>setKind(e.target.value)}><option value="profile">Perfil deste serviço · MZN · IVA geral 16% · preço sem IVA</option><option value="temporal">Decisão temporal de um rascunho</option></select></label>
|
||||
<label>Identificador da proposta<input value={form.identifier} required onChange={e=>setForm({...form,identifier:e.target.value})}/></label><label>ID da versão anterior (apenas correcção)<input type="number" min="1" value={form.previous_id} onChange={e=>setForm({...form,previous_id:e.target.value})}/></label>
|
||||
{kind==='temporal' && <label>Acontecimento<select value={form.event} onChange={e=>setForm({...form,event:e.target.value,event_date:''})}><option value="pending">Pendente — não determinar exigibilidade</option><option value="invoice_in_time">Emissão dentro do prazo fundamentado</option><option value="invoice_deadline">Termo do prazo de emissão fundamentado</option></select><small>Adiantamentos e afectações parciais ficam fora deste primeiro caso.</small></label>}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function ServiceInvoiceWorkspace({api,context,can,perform,busy})
|
||||
const unit=context.unit?.id;
|
||||
const fields=kind==='series'?{code:'Código da série de teste (TEST-...)',first_number:'Primeiro número de teste',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',evidence:'Fundamento da configuração sintética'}:{draft_id:'Rascunho existente',draft_version:'Versão do rascunho',preparation_version:'Versão da preparação',profile_proposal_id:'Proposta do perfil fiscal',valid_from:'Vigência desde',valid_until:'Vigência até (opcional)',period_start:'Início da cobertura',period_end:'Fim da cobertura',issue_window_start:'Janela fundamentada de emissão desde',issue_window_end:'Janela fundamentada até',readiness_basis:'Fundamento da prontidão documental e representação',evidence:'Evidências e pressupostos sintéticos'};
|
||||
async function propose(e){e.preventDefault();await perform(async()=>{const data={};for(const field of Object.keys(fields))if(f[field]!=='')data[field]=field.endsWith('_id')||field.endsWith('_version')||field==='first_number'?Number(f[field]):f[field];if(kind==='series')data.state=f.state;else data.event='on_issue_within_deadline';await api('/api/service-invoices/proposals','POST',{kind,identifier:kind==='series'?f.code:f.identifier,...(f.previous_id?{previous_id:Number(f.previous_id)}:{}),data});await load();},'Proposta de teste submetida para validação.');}
|
||||
return <section className="fiscal-workspace invoice-test-workspace"><p className="eyebrow">DEMONSTRAÇÃO DOCUMENTAL ISOLADA</p><h1>Facturas de teste</h1><div className="fiscal-notice"><strong>{notice}</strong><span>Emissão real bloqueada. Sem comunicação à AT, envio automático, recebimentos ou lançamentos.</span></div>{error&&<p role="alert">{error}</p>}
|
||||
return <section className="fiscal-workspace invoice-test-workspace"><p className="eyebrow">{context.single_installation?'SGF · COMERCIAL':'DEMONSTRAÇÃO DOCUMENTAL ISOLADA'}</p><h1>Facturas de teste</h1><div className="fiscal-notice"><strong>{notice}</strong><span>Emissão real bloqueada. Sem comunicação à AT ou envio automático. Recebimentos e lançamentos mantêm decisões próprias.</span></div>{error&&<p role="alert">{error}</p>}
|
||||
{!context.unit?<p>Seleccione uma unidade autorizada.</p>:!list?<p>A carregar…</p>:!list.test_enabled?<div className="panel"><h2>Emissão real indisponível</h2><p>{list.notice}</p></div>:<>
|
||||
{(can('invoice_series.propose')||can('service_fiscal.propose'))&&<details className="panel fiscal-new"><summary>Preparar série ou regra temporal de teste</summary><form onSubmit={propose}><label>Tipo<select value={kind} onChange={e=>setKind(e.target.value)}><option value="series">Configuração de série sintética</option><option value="temporal">Regra temporal prospectiva</option></select></label>{kind==='temporal'&&<label>Identificador da regra<input required value={f.identifier} onChange={e=>setF({...f,identifier:e.target.value})}/></label>}<label>Proposta anterior (apenas nova versão)<input type="number" min="1" value={f.previous_id} onChange={e=>setF({...f,previous_id:e.target.value})}/></label>{kind==='series'&&<label>Estado da série<select value={f.state} onChange={e=>setF({...f,state:e.target.value})}><option value="open">Aberta</option><option value="closed">Encerrada</option></select></label>}{Object.entries(fields).map(([field,label])=><label key={field}>{label}{['evidence','readiness_basis'].includes(field)?<textarea required rows={4} value={f[field]} onChange={e=>setF({...f,[field]:e.target.value})}/>:<input type={field.includes('window')||field.startsWith('valid_')||field.startsWith('period_')?'date':'text'} required={field!=='valid_until'} value={f[field]} onChange={e=>setF({...f,[field]:e.target.value})}/>}</label>)}<button className="primary" disabled={busy||!can(kind==='series'?'invoice_series.propose':'service_fiscal.propose')}>Submeter proposta</button></form></details>}
|
||||
<h2>Séries e regras temporais</h2>{list.proposals.map(p=><article className="fiscal-proposal" key={p.id}><div className="fiscal-card-heading"><div><p className="eyebrow">{p.kind==='series'?'SÉRIE SINTÉTICA':'REGRA TEMPORAL'}</p><h3>{p.identifier} · v{p.version}</h3><p>Proposta #{p.id} · {p.decision?.decision==='approved'?'Validada':p.decision?.decision==='refused'?'Recusada':'Aguarda validação'}</p></div></div>{p.kind==='series'?<p>Número inicial: {p.definition.first_number} · {p.definition.state==='open'?'Aberta':'Encerrada'} · Desde {p.definition.valid_from}</p>:<p>Rascunho #{p.definition.draft_id} · Emissão dentro da janela fundamentada {p.definition.issue_window_start} a {p.definition.issue_window_end}. A data efectiva só é fixada na confirmação.</p>}<p>{p.definition.evidence}</p>{p.definition.readiness_basis&&<p>{p.definition.readiness_basis}</p>}{!p.decision&&can(p.kind==='series'?'invoice_series.approve':'service_fiscal.approve')&&<div className="fiscal-review"><label>Fundamento da decisão<textarea value={reasons[p.id]||''} onChange={e=>setReasons({...reasons,[p.id]:e.target.value})}/></label><div className="actions">{['approved','refused'].map(decision=><button key={decision} className={decision==='approved'?'primary':''} disabled={busy||!reasons[p.id]} onClick={()=>perform(async()=>{await api(`/api/service-invoices/proposals/${p.id}/decision`,'POST',{version:p.version,decision,reason:reasons[p.id]});await load();},'Decisão registada apenas para a versão de teste.')}>{decision==='approved'?'Validar versão':'Recusar versão'}</button>)}</div></div>}</article>)}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {selectedReceivable} from './receivable-position-view.js';
|
||||
|
||||
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);
|
||||
export default function ServiceReceivablesWorkspace({context, can, api, busy, initialObligationId=null}) {
|
||||
const [data,setData]=useState(null), [error,setError]=useState(''), [working,setWorking]=useState(false), [obId,setObId]=useState(initialObligationId===null?'':String(initialObligationId)), [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 ob=selectedReceivable(data?.obligations,obId);
|
||||
const record=id=>data?.records.find(r=>r.id===Number(id));
|
||||
const origin=ob && record(ob.id);
|
||||
const receipts=ob ? Object.values(ob.receipts) : [];
|
||||
@@ -32,10 +33,10 @@ export default function ServiceReceivablesWorkspace({context, can, api, busy}) {
|
||||
}
|
||||
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>
|
||||
<section className="panel"><p className="eyebrow">FINANCEIRO</p><h1>Recebimentos, afectações e comprovativos</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. A emissão do recibo documental e a conferência contabilística têm percursos próprios; esta página não executa transferências bancárias nem lança automaticamente.</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>
|
||||
<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);}}><option value="">Escolha explicitamente uma posição</option>{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>{data&&obId&&!ob&&<p role="alert">A posição escolhida não está disponível nesta consulta. Não foi seleccionada outra posição.</p>}{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>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {milliQuantity} from './StockReadWorkspace.jsx';
|
||||
import {requestStockPeriod} from './stock-period-request.js';
|
||||
|
||||
export default function StockInactiveReportWorkspace({api,context}) {
|
||||
const [warehouseQuery,setWarehouseQuery]=useState({view:'warehouses',page:1,per_page:100}),[warehouses,setWarehouses]=useState(null),[warehouseError,setWarehouseError]=useState(''),[warehouseLoading,setWarehouseLoading]=useState(false),[warehouse,setWarehouse]=useState('');
|
||||
useEffect(()=>requestStockPeriod({query:warehouseQuery,api,data:setWarehouses,error:setWarehouseError,loading:setWarehouseLoading}),[warehouseQuery,context.token]);
|
||||
const [query,setQuery]=useState(null),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
|
||||
useEffect(()=>{if(!query)return;return requestStockPeriod({query,api,data:setData,error:setError,loading:setLoading});},[query,context.token]);
|
||||
const submit=e=>{e.preventDefault();const f=Object.fromEntries(new FormData(e.currentTarget));setQuery({view:'inactive',as_of:f.as_of,days:Number(f.days),search:f.search,warehouse_id:f.warehouse_id||undefined,per_page:Number(f.per_page),page:1});};
|
||||
return <section aria-label="Produtos sem movimento"><h3>Pares artigo/armazém com história conhecida</h3><p>Escolha o corte UTC e o número de dias consecutivos sem registo físico, incluindo o dia de corte. Artigos sem história conhecida ficam fora desta conclusão.</p>
|
||||
<form onSubmit={submit}><label>Dia de corte (UTC)<input name="as_of" type="date" min="1900-01-01" max="9998-12-31" required/></label><label>Dias sem movimento<input name="days" type="number" min="1" max="3650" defaultValue="30" required/></label><label>Código ou nome<input name="search" maxLength={100}/></label><label>Armazém<select name="warehouse_id" value={warehouse} onChange={e=>setWarehouse(e.target.value)}><option value="">Todos os autorizados</option>{warehouse&&!warehouses?.rows.some(r=>String(r.id)===warehouse)&&<option value={warehouse}>Armazém seleccionado #{warehouse}</option>}{warehouses?.rows.map(r=><option key={r.id} value={r.id}>{r.code} · {r.name}</option>)}</select></label><label>Linhas por página<select name="per_page" defaultValue="25"><option>25</option><option>100</option></select></label><button disabled={loading}>Consultar inactividade</button></form>{warehouseError&&<p role="alert">{warehouseError}<button onClick={()=>setWarehouseQuery({view:'warehouses',page:1,per_page:100})}>Actualizar armazéns</button></p>}{warehouses&&warehouses.pagination.last_page>1&&<nav aria-label="Páginas de armazéns"><button disabled={warehouseLoading||warehouses.pagination.page<=1} onClick={()=>setWarehouseQuery({...warehouseQuery,page:warehouses.pagination.page-1,snapshot:warehouses.snapshot})}>Armazéns anteriores</button><button disabled={warehouseLoading||warehouses.pagination.page>=warehouses.pagination.last_page} onClick={()=>setWarehouseQuery({...warehouseQuery,page:warehouses.pagination.page+1,snapshot:warehouses.snapshot})}>Mais armazéns</button></nav>}
|
||||
{loading&&<p role="status">A consultar a história autorizada…</p>}{error&&<div role="alert"><p>{error}</p><button onClick={()=>setQuery({...query,page:1,snapshot:undefined})}>Actualizar relatório</button></div>}
|
||||
{data&&<><p>{data.notice}</p><p>Corte {data.as_of} UTC · mínimo {data.minimum_days} dias · {data.pagination.total} pares · página {data.pagination.page} de {data.pagination.last_page} · observado {data.observed_at}</p><div className="table-wrap"><table><thead><tr><th>Artigo / unidade</th><th>Armazém</th><th>Último registo UTC</th><th>Dias sem registo</th><th>Quantidade física no corte</th></tr></thead><tbody>{data.rows.map(r=><tr key={r.stock_item_id+':'+r.warehouse_id}><td>{r.code} · {r.name} · {r.unit}</td><td>{r.warehouse_code} · {r.warehouse_name}</td><td>{r.last_registered_at}</td><td>{r.days_without_record}</td><td>{milliQuantity(r.physical_quantity_milli)}</td></tr>)}</tbody></table></div>{!data.rows.length&&<p>Sem pares elegíveis para estes filtros. Não significa ausência de actividade ou stock zero.</p>}<button disabled={loading||data.pagination.page<=1} onClick={()=>setQuery({...query,page:data.pagination.page-1,snapshot:data.snapshot})}>Anterior</button><button disabled={loading||data.pagination.page>=data.pagination.last_page} onClick={()=>setQuery({...query,page:data.pagination.page+1,snapshot:data.snapshot})}>Seguinte</button><button disabled={loading} onClick={()=>setQuery({...query,page:1,snapshot:undefined})}>Nova referência de leitura</button></>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import React,{useEffect,useState,lazy,Suspense} from 'react';
|
||||
import {stockSections,visibleStockSections,stockOperations,availableStockReports} from './stock-menu.js';
|
||||
import StockReadWorkspace from './StockReadWorkspace.jsx';
|
||||
import StockPeriodReportWorkspace from './StockPeriodReportWorkspace.jsx';
|
||||
|
||||
const StockInactiveReportWorkspace=lazy(()=>import('./StockInactiveReportWorkspace.jsx'));
|
||||
export default function StockMenuWorkspace({section,context,can,navigate,api,openReceiptOrigin}) {
|
||||
const [reportId,setReportId]=useState('balances');
|
||||
useEffect(()=>{setReportId('balances');},[context.token]);
|
||||
@@ -11,29 +12,35 @@ export default function StockMenuWorkspace({section,context,can,navigate,api,ope
|
||||
if(!active)return null;
|
||||
const operation=stockOperations[section];
|
||||
const canRead=Boolean(api&&context.unit&&can('stock.view',context.unit.id));
|
||||
const commands=Boolean(context.stock?.operations===true&&context.unit&&can('stock.view',context.unit.id)&&(can('stock.operations.prepare',context.unit.id)||can('stock.operations.confirm',context.unit.id)));
|
||||
const commandSection=commands&&['stock_issues','stock_transfers','stock_balances'].includes(section);
|
||||
const periodAvailable=canRead&&context.stock?.period_report===true&&reportId==='movements';
|
||||
// This SGF composition has no stock source, feature configuration or cost permission yet.
|
||||
// Do not substitute workshop rights, company rights or purchasing roles for stock rights.
|
||||
const reports=availableStockReports({},false),report=reports.find(r=>r.id===reportId)??reports[0];
|
||||
const reports=availableStockReports({history:context.stock?.inactive_report===true,...(context.stock?.final?{transit:true,lots:true,inventory:true,returns:context.stock?.completion===true}:{})},false),report=reports.find(r=>r.id===reportId)??reports[0];
|
||||
const tabs=<nav className="stock-tabs" aria-label="Submenus de stock">{visibleStockSections(can).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>;
|
||||
return <div className="stock-menu-workspace"><div className="page-title"><p className="eyebrow">STOCK</p><h1>{active.label}</h1><p>{context.company.name}{context.unit?` / ${context.unit.name}`:''}</p></div>{tabs}
|
||||
{canRead?<div className="alert home-notice" role="status"><strong>Consulta condicionada à fonte sintética autorizada.</strong><p>Entradas, saídas, transferências e custos continuam indisponíveis. A consulta não altera os saldos de Compras.</p></div>:<div className="alert home-notice" role="status"><strong>Menu em preparação — fonte de stock ainda não ligada ao SGF.</strong><p>Os saldos e movimentos de Compras permanecem na sua aplicação. Aqui não são apresentados como zero, importados ou alterados. Os apontamentos da oficina não são existências de armazém.</p></div>}
|
||||
{canRead?<div className="alert home-notice" role="status"><strong>Consulta condicionada à fonte sintética autorizada.</strong><p>As operações disponíveis dependem do perfil e do percurso indicado abaixo. A consulta não altera os saldos de Compras.</p></div>:<div className="alert home-notice" role="status"><strong>Menu em preparação — fonte de stock ainda não ligada ao SGF.</strong><p>Os saldos e movimentos de Compras permanecem na sua aplicação. Aqui não são apresentados como zero, importados ou alterados. Os apontamentos da oficina não são existências de armazém.</p></div>}
|
||||
{context.stock?.catalogue===true&&canRead&&['stock_panel','stock_balances'].includes(section)&&<section className="panel"><h2>Artigos e revisão do catálogo</h2><p>Consultar artigos existentes ou preparar um novo código, designação e unidade para revisão distinta. Aprovar disponibiliza o artigo sem saldo inicial; não cria movimentos ou custos.</p><a href="/stock/catalogue">Abrir cadastro de artigos</a></section>}
|
||||
{section==='stock_panel'&&<>
|
||||
<section className="panel"><h2>Situação actual</h2><p>Não há indicadores de stock disponíveis nesta composição. Não é possível determinar produtos abaixo do mínimo, entradas/saídas, valor do stock ou mercadoria em trânsito.</p><p>O painel sinalizará situações actuais; os relatórios permitirão investigar os movimentos e o período. Nenhum indicador é calculado a partir de exemplos ilustrativos.</p></section>
|
||||
<section className="panel"><h2>Situação actual</h2>{canRead?<><p>Consulte o mesmo livro físico de Compras, os movimentos por período e as operações autorizadas pelo seu perfil.</p>{context.stock?.final===true&&<a href="/stock/final">Abrir trânsito, lotes e inventário</a>}</>:<p>Seleccione um perfil com acesso à fonte física para consultar as existências.</p>}</section>
|
||||
<section className="panel"><h2>Escolher percurso</h2><div className="stock-route-grid">{stockSections.filter(s=>s.id!=='stock_panel').map(s=><button key={s.id} onClick={()=>navigate(s.id)}>{s.label}<span>Consultar o âmbito e o estado de disponibilidade →</span></button>)}</div></section>
|
||||
</>}
|
||||
{section==='stock_balances'&&canRead&&<StockReadWorkspace key={context.token} api={api} context={context} openReceiptOrigin={openReceiptOrigin}/>}
|
||||
{context.stock?.completion===true&&canRead&&['stock_panel','stock_balances','stock_returns','stock_reports'].includes(section)&&can('stock.planning.view',context.unit.id)&&<section className="panel"><h2>Políticas e devoluções ao fornecedor</h2><p>Definir mínimos com revisão distinta e preparar a devolução física pela recepção exacta. Aprovação, saída e valorização são passos individuais; não há compra ou crédito financeiro automático.</p><a href="/stock/completion">Abrir políticas e devoluções ao fornecedor</a></section>}
|
||||
{section==='stock_balances'&&!canRead&&<>
|
||||
<section className="panel"><h2>Consulta de existências indisponível</h2><p>Falta a fonte autorizada de produtos, armazéns e saldos desta empresa. Não há uma lista vazia de existências nem um saldo editável.</p><dl className="stock-definitions"><div><dt>Físico</dt><dd>Quantidade efectivamente registada no armazém, incluindo reservado e bloqueado.</dd></div><div><dt>Reservado</dt><dd>Quantidade comprometida com documentos próprios, quando o controlo estiver activo.</dd></div><div><dt>Bloqueado</dt><dd>Quantidade indisponível por condição ou qualidade, sem sobreposição com reservas.</dd></div><div><dt>Disponível</dt><dd>Físico menos reservado e bloqueado. Mercadoria em trânsito não está disponível no destino.</dd></div></dl><p>Se uma dessas parcelas não estiver sustentada, o disponível fica indeterminado; não se usa zero como substituto.</p></section>
|
||||
<section className="panel"><h2>Pesquisa e rastreabilidade previstas</h2><p>Produto por código, designação ou código de barras; armazém, categoria e situação de stock. Localização, lote, série e validade só serão apresentados se configurados.</p><p>A ficha dará acesso ao movimento e ao documento original. Diferenças físicas serão tratadas por contagem e ajuste autorizado em Inventário, nunca por edição directa do saldo.</p><button onClick={()=>navigate('stock_reports')}>Ver relatórios de controlo</button></section>
|
||||
</>}
|
||||
{operation&&<>
|
||||
{commandSection&&<section className="panel"><h2>Registar operação física</h2><p>Conferir o saldo actual, preparar o impacto e confirmar uma saída ou transferência pelos controlos originais de armazém. A transferência é imediata e atómica entre os dois armazéns; não representa mercadoria em trânsito.</p><a href="/stock/operations">Abrir operações de Stock</a></section>}
|
||||
{context.stock?.final===true&&canRead&&['stock_transfers','stock_inventory','stock_balances'].includes(section)&&<section className="panel"><h2>Trânsito e controlo por lotes</h2><p>Expedição e recepção distintas, identificação, retenção, libertação, contagem e ajuste conferido. Cada decisão exige autoridade própria e nova conferência do estado.</p><a href="/stock/final">Abrir o percurso de Stock</a></section>}
|
||||
{context.single_installation&&canRead&§ion==='stock_receipts'&&<section className="panel"><h2>Entradas pela recepção de Compras</h2><p>A ordem e a recepção aceite alimentam este mesmo livro físico. A factura não cria outra entrada.</p>{can('purchasing_receipts.view',context.unit.id)?<button onClick={()=>navigate('purchasing_home')}>Escolher Compras e abrir as recepções</button>:<a href="/sgf">Escolher uma função de recepção de Compras</a>}</section>}
|
||||
{context.single_installation&&canRead&§ion==='stock_returns'&&<section className="panel"><h2>Material não usado da oficina</h2><p>A declaração mantém a entrega de origem, a quantidade elegível e o comprovativo. Outra função recebe fisicamente a devolução no mesmo armazém, sem apagar a saída ou repetir o consumo.</p>{can('workshop_stock.view',context.unit.id)?<button onClick={()=>navigate('workshop_stock')}>Escolher Oficinas e consultar entrega/devolução</button>:<a href="/sgf">Escolher a função autorizada de Oficina</a>}<p>Devoluções comerciais de clientes ou a fornecedores exigem os seus documentos próprios; não são inferidas deste retorno de material.</p></section>}
|
||||
{operation&&!commandSection&&!(context.single_installation&&canRead&&['stock_receipts','stock_returns'].includes(section))&&!(context.stock?.final===true&§ion==='stock_inventory')&&<>
|
||||
<section className="panel"><h2>{operation.title}</h2><p>{operation.purpose}</p><p className="stock-unavailable">Operação indisponível no SGF. Não há confirmação ou gravação neste ecrã.</p><h3>Campos do percurso previsto</h3><ul>{operation.fields.map(f=><li key={f}>{f}</li>)}</ul><p>Localizações, lotes, séries, validade e câmbio só entram quando o produto e a configuração os exigirem. Não são assumidos por omissão.</p></section>
|
||||
<section className="panel"><h2>Etapas previstas</h2><ol>{operation.flow.map(step=><li key={step}>{step}</li>)}</ol><h3>O que já existe e o que falta</h3><p>{operation.existing}</p><p>{operation.missing}</p><p><strong>Prevenção de duplicações:</strong> {operation.control}</p></section>
|
||||
</>}
|
||||
{section==='stock_reports'&&<>
|
||||
<section className="panel"><h2>Relatórios essenciais</h2><label>Relatório<select value={reportId} onChange={e=>setReportId(e.target.value)}>{reports.map(r=><option key={r.id} value={r.id}>{r.label}</option>)}</select></label><h3>{report.label}</h3><p><strong>Base temporal:</strong> {report.basis}.</p><p><strong>Informação prevista:</strong> {report.fields}</p>{periodAvailable?<StockPeriodReportWorkspace key={context.token} api={api} context={context}/>:<><p role="status"><strong>Consulta indisponível.</strong> {report.missing}</p><p>Não é possível gerar, imprimir ou exportar um relatório sem a sua fonte. A falta de informação não é apresentada como ausência de movimentos.</p></>}</section>
|
||||
<section className="panel"><h2>Disponibilidade dos restantes relatórios</h2><p>Valorização exige custos, método configurado e permissão própria. Dados de custos não são carregados por este menu. Relatórios de trânsito, devoluções, lotes, inactividade e diferenças de inventário serão apresentados quando os respectivos percursos estiverem activos.</p><p>A consulta e a exportação terão de preservar os mesmos filtros, autorizações, abertura e fecho. Reservas e bloqueios históricos não serão substituídos por valores actuais.</p></section>
|
||||
<section className="panel"><h2>Relatórios essenciais</h2><label>Relatório<select value={reportId} onChange={e=>setReportId(e.target.value)}>{reports.map(r=><option key={r.id} value={r.id}>{r.label}</option>)}</select></label><h3>{report.label}</h3><p><strong>Base temporal:</strong> {report.basis}.</p><p><strong>Informação prevista:</strong> {report.fields}</p>{context.stock?.final===true&&['transit','expiry','inventory'].includes(reportId)?<><p>Consulta actual no percurso integrado, com trânsito por receber e quantidades por lote. Os movimentos por período têm filtro próprio.</p><a href="/stock/final">Consultar trânsito, lotes e inventário</a></>:context.stock?.completion===true&&can('stock.planning.view',context.unit?.id)&&['minimum','returns'].includes(reportId)?<><p>Consulta dos mínimos revistos, saldo físico e devoluções da origem QA autorizada. A falta de política nos restantes artigos continua indeterminada.</p><a href="/stock/completion">Consultar políticas, mínimos e devoluções</a></>:canRead&&reportId==='balances'?<StockReadWorkspace key={context.token} api={api} context={context} openReceiptOrigin={openReceiptOrigin}/>:canRead&&context.stock?.inactive_report===true&&reportId==='inactive'?<Suspense fallback={<p>A abrir relatório…</p>}><StockInactiveReportWorkspace key={context.token} api={api} context={context}/></Suspense>:periodAvailable?<StockPeriodReportWorkspace key={context.token} api={api} context={context}/>:<><p role="status"><strong>Consulta indisponível.</strong> {report.missing}</p><p>Não é possível gerar, imprimir ou exportar um relatório sem a sua fonte. A falta de informação não é apresentada como ausência de movimentos.</p></>}</section>
|
||||
<section className="panel"><h2>Disponibilidade dos restantes relatórios</h2><p>Valorização exige custos, método configurado e permissão própria. Dados de custos não são carregados por este menu. As consultas disponíveis estão no selector acima e nos percursos de trânsito, lotes, inventário e políticas autorizados. As restantes definições não substituem uma fonte implementada.</p><p>A consulta e a exportação terão de preservar os mesmos filtros, autorizações, abertura e fecho. Reservas e bloqueios históricos não serão substituídos por valores actuais.</p></section>
|
||||
</>}
|
||||
<section className="panel"><h2>Regras comuns do percurso</h2><p>Um rascunho não altera stock. A confirmação revalida permissões e quantidades, tem um único efeito e conserva actor, data, motivo e origem. Movimentos confirmados não são editados ou apagados; correcções exigem operação própria ligada ao original.</p><p>Acesso a este mapa não concede consulta de armazéns, custos ou movimentos. A integração operacional terá autorizações específicas e contexto explícito, preservando as diferenças entre empresa, unidade e armazém.</p></section>
|
||||
</div>;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {workshopBillingProposal} from './workshopBillingProposal.js';
|
||||
export default function WorkshopBillingWorkspace({context,can,api,busy}){
|
||||
export default function WorkshopBillingWorkspace({context,can,api,busy,navigate}){
|
||||
const [data,setData]=useState(null),[orderId,setOrderId]=useState(''),[error,setError]=useState(''),[working,setWorking]=useState(false),[notice,setNotice]=useState('');const retry=useRef(null);const feedback=useRef(null);
|
||||
const allowed=r=>can(`workshop_billing.${r}`,context.unit?.id??null);const load=async()=>setData(await api('/api/workshop-billing'));
|
||||
useEffect(()=>{setData(null);setOrderId('');retry.current=null;load().catch(e=>setError(e.message));},[context.token]);
|
||||
const order=data?.orders.find(o=>String(o.id)===orderId)??data?.orders[0],disabled=busy||working;
|
||||
const order=data?.orders.find(o=>String(o.id)===orderId)??null,disabled=busy||working;
|
||||
useEffect(()=>{if(error||notice)feedback.current?.scrollIntoView({behavior:'auto',block:'center'});},[error,notice]);
|
||||
async function send(action,input){setWorking(true);setError('');setNotice('');const path=`/api/workshop-billing/orders/${order.id}/${action}`,signature=JSON.stringify([path,input]);if(retry.current?.signature!==signature)retry.current={signature,key:crypto.randomUUID()};try{const result=await api(path,'POST',input,context.token,retry.current.key);retry.current=null;setNotice(action==='proposals'?`Proposta #${result.id} · v${result.version} registada. Aguarda revisão distinta.`:action==='reviews'?'Revisão comercial registada.':'Rascunho gerado.');try{await load();}catch{setError('O registo foi guardado, mas a lista não actualizou. Use Actualizar versões explicitamente; não volte a submeter.');}}catch(e){setError(e.message);}finally{setWorking(false);}}
|
||||
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>
|
||||
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)}><option value="">Escolha explicitamente a ordem</option>{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 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>;
|
||||
{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>{navigate&&can('billing_drafts.view',context.unit.id)&&<button disabled={disabled} onClick={()=>navigate('billing_drafts',{area:'billing_drafts',id:order.generation.latest_commercial_change?.new_billing_id??order.generation.billing_draft_id})}>Escolher Comercial e abrir este rascunho</button>}<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,24 @@
|
||||
import React,{useEffect,useMemo,useState}from'react';
|
||||
const WorkshopOrderList=React.lazy(()=>import('./WorkshopOrderList.jsx'));
|
||||
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
|
||||
const params=value=>new URLSearchParams(Object.fromEntries(Object.entries(value).filter(([,v])=>v!==''&&v!==null))).toString();
|
||||
export function EquipmentPicker({api,context,clientId,disabled}){
|
||||
const[query,setQuery]=useState({search:'',page:1,per_page:25}),[search,setSearch]=useState(''),[data,setData]=useState(null),[selected,setSelected]=useState(''),[error,setError]=useState('');
|
||||
useEffect(()=>{setSelected('');setData(null);},[clientId,context.token]);useEffect(()=>{if(!clientId)return;let active=true;setError('');api('/api/workshop-equipment?'+params({...query,client_id:clientId})).then(r=>{if(active)setData(r.records);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[clientId,context.token,query]);
|
||||
return <fieldset disabled={disabled||!clientId}><label>Pesquisar equipamento do cliente<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>{setSelected('');setQuery({...query,search,page:1});}}>Pesquisar equipamento</button>{error&&<p role="alert">{error}</p>}<label>Equipamento existente<select name="equipment_id" required value={selected} onChange={e=>setSelected(e.target.value)}><option value="">Escolher equipamento…</option>{data?.data.map(e=><option key={e.id} value={e.id}>{e.identifier} · {e.description} · {e.serial??'sem série'}</option>)}</select></label>{data&&<><button type="button" disabled={data.current_page<=1} onClick={()=>{setSelected('');setQuery({...query,page:query.page-1});}}>Anterior</button><button type="button" disabled={data.current_page>=data.last_page} onClick={()=>{setSelected('');setQuery({...query,page:query.page+1});}}>Seguinte</button></>}</fieldset>;
|
||||
}
|
||||
export default function WorkshopEquipmentWorkspace({api,context,can,navigate}){
|
||||
const [selectedEquipment,setSelectedEquipment]=useState(null);
|
||||
const[query,setQuery]=useState({search:'',page:1,per_page:25}),[search,setSearch]=useState(''),[data,setData]=useState(null),[clients,setClients]=useState(null),[clientQuery,setClientQuery]=useState({search:'',page:1,per_page:25}),[clientSearch,setClientSearch]=useState(''),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[notice,setNotice]=useState('');const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),create=can('workshop.open',context.unit?.id),locked=busy||command.uncertain;
|
||||
const load=async()=>setData((await api('/api/workshop-equipment?'+params(query))).records);
|
||||
useEffect(()=>{let active=true;api('/api/workshop-equipment?'+params(query)).then(r=>{if(active)setData(r.records);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,query]);
|
||||
useEffect(()=>{if(!create)return;let active=true;api('/api/workshop-equipment?'+params({view:'clients',...clientQuery})).then(r=>{if(active)setClients(r.records);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[context.token,clientQuery,create]);
|
||||
const prepare=async e=>{e.preventDefault();setBusy(true);setError('');try{setPreview(await api('/api/workshop-equipment/preview','POST',{...form,client_id:Number(form.client_id)},undefined,crypto.randomUUID()));}catch(e){setError(e.message);}finally{setBusy(false);}};
|
||||
const confirm=async retry=>{setBusy(true);setError('');try{const r=retry?await command.retry():await command.send('/api/workshop-equipment/confirm',{preview:preview.preview},crypto.randomUUID());setNotice(`Equipamento registado${r.replayed?' · recibo recuperado':''}. Pode ser escolhido numa nova ordem.`);setForm(null);setPreview(null);await load();}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}};
|
||||
if(selectedEquipment)return <React.Suspense fallback={<p>A abrir ordens do equipamento…</p>}><WorkshopOrderList key={context.token+':'+selectedEquipment.id} context={context} api={api} navigate={navigate} equipmentId={selectedEquipment.id} onBack={()=>setSelectedEquipment(null)}/></React.Suspense>;
|
||||
return <section className="panel" data-sgf-operation-uncertain={command.uncertain||preview?'true':undefined}><h2>Equipamentos do módulo</h2><p>Cadastro individual por cliente, código, descrição/marca/modelo e série. Reutilize a identificação ao abrir outra ordem. As identificações existentes permanecem imutáveis.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<form onSubmit={e=>{e.preventDefault();setQuery({...query,search,page:1});}}><fieldset disabled={locked}><label>Pesquisar código, descrição ou série<input value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Por página<select value={query.per_page} onChange={e=>setQuery({...query,per_page:Number(e.target.value),page:1})}><option value="25">25</option><option value="100">100</option></select></label><button>Pesquisar</button></fieldset></form>
|
||||
{create&&<button disabled={locked||Boolean(form)} onClick={()=>setForm({client_id:'',identifier:'',description:'',serial:'',reason:''})}>Registar equipamento</button>}{navigate&&<button disabled={locked||Boolean(preview)} onClick={()=>navigate('workshop')}>Abrir ordens e reutilizar equipamento</button>}
|
||||
{data&&<><div className="table-scroll"><table><thead><tr><th>Código</th><th>Descrição</th><th>Série</th><th>Cliente</th><th>Ordens</th></tr></thead><tbody>{data.data.map(e=><tr key={e.id}><td>{e.identifier}</td><td>{e.description}</td><td>{e.serial??'Não registada'}</td><td>{e.client_name}</td><td><button disabled={locked||Boolean(form)||Boolean(preview)} onClick={()=>setSelectedEquipment(e)}>Consultar ordens deste equipamento</button></td></tr>)}</tbody></table></div>{!data.data.length&&<p>Sem equipamentos visíveis para esta pesquisa.</p>}<button disabled={locked||data.current_page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Anterior</button><span> Página {data.current_page}/{data.last_page} </span><button disabled={locked||data.current_page>=data.last_page} onClick={()=>setQuery({...query,page:query.page+1})}>Seguinte</button></>}
|
||||
{form&&<section className="panel"><h3>Nova identificação</h3><fieldset disabled={locked||Boolean(preview)}><label>Pesquisar cliente<input value={clientSearch} onChange={e=>setClientSearch(e.target.value)}/></label><button type="button" onClick={()=>setClientQuery({...clientQuery,search:clientSearch,page:1})}>Pesquisar cliente</button>{clients&&<><button type="button" disabled={clients.current_page<=1} onClick={()=>setClientQuery({...clientQuery,page:clientQuery.page-1})}>Anterior</button><button type="button" disabled={clients.current_page>=clients.last_page} onClick={()=>setClientQuery({...clientQuery,page:clientQuery.page+1})}>Seguinte</button></>}</fieldset><form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}><label>Cliente<select required value={form.client_id} onChange={e=>setForm({...form,client_id:e.target.value})}><option value="">Escolher cliente…</option>{clients?.data.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</select></label>{[['identifier','Código individual'],['description','Descrição / marca / modelo'],['serial','Série (opcional)'],['reason','Fundamento']].map(([key,label])=><label key={key}>{label}<input required={key!=='serial'} minLength={key==='reason'?10:undefined} maxLength={key==='description'?300:key==='reason'?2000:100} value={form[key]} onChange={e=>{setPreview(null);setForm({...form,[key]:e.target.value});}}/></label>)}<button>Conferir identificação</button></fieldset></form>{preview&&<div role="status"><p>Registar {preview.impact.identifier} · {preview.impact.description} para {preview.impact.client}. Não abre ordem nem cria efeitos físicos/financeiros.</p><button disabled={locked} onClick={()=>confirm(false)}>Confirmar registo</button></div>}{command.uncertain&&<button disabled={busy} onClick={()=>confirm(true)}>Recuperar confirmação</button>}<button disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar</button></section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {workshopSections,visibleWorkshopSections,workshopStates,filterWorkshopOrders,visibleWorkshopEquipment} from './workshop-menu.js';
|
||||
import WorkshopStockWorkspace from './WorkshopStockWorkspace.jsx';
|
||||
import WorkshopScheduleWorkspace from './WorkshopScheduleWorkspace.jsx';
|
||||
import WorkshopEquipmentWorkspace from './WorkshopEquipmentWorkspace.jsx';
|
||||
const WorkshopOrderList=React.lazy(()=>import('./WorkshopOrderList.jsx'));
|
||||
|
||||
export default function WorkshopMenuWorkspace({section,context,can,api,navigate}) {
|
||||
const unit=context.unit?.id??null,active=workshopSections.find(s=>s.id===section);
|
||||
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[search,setSearch]=useState(''),[state,setState]=useState('');
|
||||
useEffect(()=>{
|
||||
let current=true;setData(null);setError('');
|
||||
if(unit===null||!can('workshop.view',unit)||!['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section))return ()=>{current=false;};
|
||||
let current=true;setData(null);setError('');if((section==='workshop_equipment'&&context.workshop_catalogue_enabled===true)||(context.single_installation&&['workshop_panel','workshop_team','workshop_reports'].includes(section)))return()=>{current=false;};
|
||||
if(unit===null||!can('workshop.view',unit)||!['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section)&&!(section==='workshop_equipment'&&context.workshop_catalogue_enabled===true))return ()=>{current=false;};
|
||||
api('/api/workshop').then(d=>{if(current)setData(d);}).catch(e=>{if(current)setError(e.message);});
|
||||
return ()=>{current=false;};
|
||||
},[context.token,section,reload]);
|
||||
@@ -18,17 +21,22 @@ export default function WorkshopMenuWorkspace({section,context,can,api,navigate}
|
||||
return <div className="workshop-menu-workspace"><div className="page-title"><p className="eyebrow">OFICINAS</p><h1>{active.label}</h1><p>{context.company.name} / {context.unit?.name??'Seleccione uma unidade'}</p></div>
|
||||
<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,unit).map(s=><button key={s.id} aria-current={s.id===section?'page':undefined} onClick={()=>navigate(s.id)}>{s.label}</button>)}</nav>
|
||||
{section==='workshop_stock'&&unit!==null&&can('workshop_stock.view',unit)&&<WorkshopStockWorkspace key={context.token} api={api} context={context} can={can}/>}
|
||||
{section==='workshop_schedule'&&context.workshop_schedule_enabled===true&&<WorkshopScheduleWorkspace key={context.token} api={api} context={context} can={can}/>}
|
||||
{section==='workshop_equipment'&&context.workshop_catalogue_enabled===true&&<WorkshopEquipmentWorkspace key={context.token} api={api} context={context} can={can} navigate={navigate}/>}
|
||||
<p className="alert home-notice">Demonstração sintética. O estado operacional, a autorização de trabalho, a facturação e o pagamento são distintos. Abrir o menu não altera permissões nem confirma uma operação.</p>
|
||||
{active.pending&&!(section==='workshop_stock'&&can('workshop_stock.view',unit))&&<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{['workshop_stock','workshop_quotes'].includes(section)&&<button onClick={()=>navigate('workshop')}>Consultar ordens, autorizações e apontamentos existentes</button>}{section==='workshop_stock'&&can('company.view')&&<button onClick={()=>navigate('stock_panel')}>Abrir o menu de Stock — em preparação</button>}{section==='workshop_purchasing'&&<div className="actions">{link('purchasing_budget','Controlo orçamental disponível','purchasing_budget.view',unit)}{link('supplier_payables','Obrigações a fornecedores disponíveis','supplier_payables.view',unit)}</div>}</section>}
|
||||
{context.single_installation&§ion==='workshop_purchasing'&&<section className="panel"><h2>Compras na mesma instalação</h2><p>Requisições, propostas, ordens, recepções e obrigações mantêm os documentos e decisões próprios. Escolha explicitamente Compras e uma função autorizada para continuar.</p><a href="/sgf">Escolher função e módulo de Compras</a></section>}
|
||||
{context.commercial_quotes_enabled===true&§ion==='workshop_quotes'&&<section className="panel"><h2>Orçamentos de reparação</h2><p>Proposta ligada à ordem, revisão comercial distinta e decisão documentada do cliente. Não substitui a autorização técnica de trabalho.</p>{can('commercial_quotes.view',unit)?<button onClick={()=>navigate('billing_quotes')}>Escolher Comercial e abrir orçamentos</button>:<a href="/sgf">Escolher a função QA de preparação/revisão comercial</a>}</section>}
|
||||
{active.pending&&!(context.commercial_quotes_enabled===true&§ion==='workshop_quotes')&&!(section==='workshop_schedule'&&context.workshop_schedule_enabled===true)&&!(context.single_installation&§ion==='workshop_purchasing')&&!(section==='workshop_stock'&&can('workshop_stock.view',unit))&&<section className="panel"><h2>Capacidade ainda não disponível</h2><p>{active.pending}</p>{['workshop_stock','workshop_quotes'].includes(section)&&<button onClick={()=>navigate('workshop')}>Consultar ordens, autorizações e apontamentos existentes</button>}{section==='workshop_stock'&&can('company.view')&&<button onClick={()=>navigate('stock_panel')}>Abrir o menu de Stock</button>}{section==='workshop_purchasing'&&<div className="actions">{link('purchasing_budget','Controlo orçamental disponível','purchasing_budget.view',unit)}{link('supplier_payables','Obrigações a fornecedores disponíveis','supplier_payables.view',unit)}</div>}</section>}
|
||||
{section==='workshop_clients'&&<section className="panel"><h2>Cadastro de clientes existente</h2><p>Reutilize o cadastro da empresa; não há um segundo registo de clientes para a oficina. Conta corrente e histórico de manutenção completo não são acrescentados por este menu.</p>{link('clients','Abrir clientes','clients.view')}</section>}
|
||||
{section==='workshop_finance'&&<section className="panel"><h2>Documentos e percursos financeiros existentes</h2><div className="actions">{link('service_invoices','Facturas e documentos de teste','service_invoices.view',unit)}{link('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit)}{link('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)}{link('service_receivables','Recebimentos e afectações de teste','service_receivables.view',unit)}</div><p>Caixa, despesas, abertura e fecho de caixa ainda não estão implementados. Não se apresentam saldos fictícios, margens ou valores recebidos inferidos a partir de facturas.</p></section>}
|
||||
{section==='workshop_finance'&&<section className="panel"><h2>Documentos e percursos financeiros existentes</h2><div className="actions">{link('service_invoices','Facturas e documentos de teste','service_invoices.view',unit)}{link('billing_drafts','Rascunhos de facturação','billing_drafts.view',unit)}{link('workshop_billing','Oficina → rascunho','workshop_billing.view',unit)}{link('service_receivables','Recebimentos e afectações de teste','service_receivables.view',unit)}</div>{context.cash_enabled&&can('cash.view',unit)?<><button onClick={()=>navigate('cash')}>Escolher Financeiro e abrir Caixa QA</button><p>A custódia de Caixa exige classificação explícita e revisão distinta; não é inferida das facturas.</p></>:<p>Caixa, despesas, abertura e fecho não estão disponibilizados neste contexto.</p>}</section>}
|
||||
{section==='workshop_settings'&&<section className="panel"><h2>Configurações existentes</h2><div className="actions">{link('company','Dados da empresa','company.view')}{link('access','Acessos autorizados','access.manage')}{link('services','Catálogo de serviços','services.view')}{link('preview_model','Modelo de apresentação documental','issuer_documents.view')}{link('service_fiscal','Perfis fiscais existentes','service_fiscal.view')}</div><p>Catálogo de tipos de equipamento, características técnicas, política de oficina e gestão de cópias de segurança ainda não têm interface própria. Os perfis do documento são uma proposta; não atribuem direitos automaticamente.</p></section>}
|
||||
{['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section)&&<>
|
||||
{context.single_installation&&['workshop_panel','workshop_team','workshop_reports'].includes(section)&&unit!==null&&can('workshop.view',unit)&&<React.Suspense fallback={<p>A abrir ordens…</p>}><WorkshopOrderList report={section==='workshop_reports'} key={context.token} context={context} api={api} navigate={navigate}/></React.Suspense>}
|
||||
{['workshop_panel','workshop_equipment','workshop_team','workshop_reports'].includes(section)&&!(context.single_installation&&['workshop_panel','workshop_team','workshop_reports'].includes(section))&&!(section==='workshop_equipment'&&context.workshop_catalogue_enabled===true)&&<>
|
||||
<div className="actions"><button onClick={()=>setReload(r=>r+1)}>Actualizar consulta</button>{can('workshop.open',unit)&&<button className="primary" onClick={()=>navigate('workshop')}>Abrir percurso de ordem de serviço</button>}</div>
|
||||
{error?<p role="alert" className="alert error">Dados indisponíveis: {error}. Não é possível concluir que existam zero ordens.</p>:!data?<p role="status">A carregar ordens autorizadas…</p>:data.blocked?<section className="panel"><h2>Ensaio indisponível neste ambiente</h2><p>O percurso existente continua limitado à base sintética autorizada.</p></section>:<>
|
||||
<section className="panel"><h2>{section==='workshop_equipment'?'Pesquisar equipamentos nas ordens autorizadas':'Pesquisar ordens autorizadas'}</h2><div className="form-grid"><label>Referência, equipamento, série, cliente ou responsável<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Estado registado<select value={state} onChange={e=>setState(e.target.value)}><option value="">Todos</option>{Object.entries(workshopStates).map(([s,label])=><option value={s} key={s}>{label}</option>)}</select></label></div>
|
||||
{section==='workshop_equipment'?<><p>Equipamentos identificados nas ordens visíveis. O cadastro independente, tipos e características técnicas ainda não estão implementados.</p><div className="table-scroll"><table><thead><tr><th>Código</th><th>Descrição</th><th>Número de série</th><th>Ordens visíveis</th></tr></thead><tbody>{visibleWorkshopEquipment(orders).map(e=><tr key={e.id}><td>{e.identifier}</td><td>{e.description}</td><td>{e.serial||'Não registado'}</td><td>{e.orders.map(id=><button key={id} onClick={()=>navigate('workshop',id)}>Ordem #{id}</button>)}</td></tr>)}</tbody></table></div>{!orders.length&&<p>Sem equipamentos nas ordens que correspondem aos filtros.</p>}</>:orderTable}</section>
|
||||
<section className="panel"><h2>{section==='workshop_team'?'Responsáveis e horas de serviço':section==='workshop_reports'?'Consulta operacional disponível':'Âmbito do painel'}</h2><p>{section==='workshop_team'?'A atribuição de responsáveis e o registo de horas mantêm-se nas ordens. O cadastro de recursos humanos, horários e disponibilidade não foram implementados.':section==='workshop_reports'?'Esta lista é uma consulta actual das ordens autorizadas. Relatório diário fechado, cortes, exportação e relatórios financeiros ainda não estão implementados.':'As ordens apresentadas respeitam a consulta e a atribuição autorizadas pelo servidor. Entradas, entregas do dia, alertas, agenda e indicadores de caixa ainda não dispõem das fontes necessárias neste percurso.'}</p><p>Os estados actuais de apontamentos não são convertidos nas fases futuras Recebida, Em reparação ou Entregue. Não se inferem margens, stock ou pendências de custódia.</p></section>
|
||||
<section className="panel"><h2>{section==='workshop_team'?'Responsáveis e horas de serviço':section==='workshop_reports'?'Consulta operacional disponível':'Âmbito do painel'}</h2><p>{section==='workshop_team'?'A atribuição de responsáveis e o registo de horas mantêm-se nas ordens. O cadastro de recursos humanos, horários e disponibilidade não foram implementados.':section==='workshop_reports'?'Esta lista é uma consulta actual das ordens autorizadas. Relatório diário fechado, cortes, exportação e relatórios financeiros ainda não estão implementados.':'As ordens apresentadas respeitam a consulta e a atribuição autorizadas pelo servidor. Entradas, entregas do dia, alertas, indicadores de caixa ainda não dispõem das fontes necessárias neste percurso. Consulte a agenda no seu submenu próprio.'}</p><p>Os estados actuais de apontamentos não são convertidos nas fases futuras Recebida, Em reparação ou Entregue. Não se inferem margens, stock ou pendências de custódia.</p></section>
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {workshopStates} from './workshop-menu.js';
|
||||
import {checkedWorkshopReport,workshopPageCsv} from './workshop-report-view.js';
|
||||
export function WorkshopOrderRow({row:r,navigate}){return <tr><td>{r.reference}<small>{r.client??'Cliente por conferir'}</small></td><td>{r.equipment??'Identificação por conferir'}<small>{r.description}</small><small>{r.serial??'Série não registada'}</small></td><td>{r.technician??'Por atribuir'}</td><td>{workshopStates[r.state]}</td><td>{r.points} registo(s)<small>{r.points_not_verified} sem verificação favorável</small></td><td>{r.minutes_reported} apontados<small>{r.minutes_verified} verificados</small></td><td><button onClick={()=>navigate('workshop',r.id)}>Abrir ordem e evidências</button></td></tr>;}
|
||||
export default function WorkshopOrderList({context,api,navigate,report=false,equipmentId=null,onBack=null}){
|
||||
const [form,setForm]=useState({search:'',state:'',per_page:'25',from:'',to:'',equipment_id:equipmentId??''}),[query,setQuery]=useState({page:1,per_page:'25',equipment_id:equipmentId??''}),[data,setData]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);
|
||||
useEffect(()=>{let current=true;setData(null);setError('');if(report&&!query.from){setLoading(false);return()=>{current=false;};}setLoading(true);api('/api/workshop/orders?'+new URLSearchParams(Object.entries(query).filter(([,v])=>v!==''))).then(v=>{if(current)setData(checkedWorkshopReport(v,query));}).catch(e=>{if(current)setError(e.message);}).finally(()=>{if(current)setLoading(false);});return()=>{current=false;};},[context.token,query,report]);
|
||||
const p=data?.orders;const exportPage=()=>{const url=URL.createObjectURL(new Blob(['\uFEFF'+workshopPageCsv(data,query)],{type:'text/csv;charset=utf-8'}));const a=document.createElement('a');a.href=url;a.download='sgf-oficina-pagina-'+p.current_page+'.csv';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
return <section className="panel"><h2>{equipmentId?'Ordens do equipamento escolhido':report?'Relatório de ordens por período de abertura':'Ordens, responsáveis e apontamentos'}</h2>{report&&<p>Escolha as datas de abertura das ordens. Os apontamentos e a identificação são os actuais; não é um fecho diário ou uma reconstituição histórica.</p>}{onBack&&<button disabled={loading} onClick={onBack}>Voltar ao cadastro de equipamentos</button>}{data?.equipment&&<section><h3>Equipamento seleccionado</h3><dl className="overview-definitions"><div><dt>Código</dt><dd>{data.equipment.identifier}</dd></div><div><dt>Descrição</dt><dd>{data.equipment.description}</dd></div><div><dt>Série</dt><dd>{data.equipment.serial??'Não registada'}</dd></div><div><dt>Cliente</dt><dd>{data.equipment.client_name}</dd></div></dl><p>A identificação é a actual; os apontamentos não representam uma reparação concluída.</p></section>}<p>Consulta actual por empresa/unidade e atribuição ao técnico. O estado resume os apontamentos actuais, não a entrega, a factura ou o pagamento.</p><form data-sgf-read-filters="true" onSubmit={e=>{e.preventDefault();setQuery({...form,page:1});}}><div className="form-grid"><label>Abertas desde<input type="date" min="2000-01-01" max="2100-12-31" required={report||Boolean(form.to)} value={form.from} onChange={e=>setForm({...form,from:e.target.value})}/></label><label>Abertas até<input type="date" min={form.from||'2000-01-01'} max="2100-12-31" required={report||Boolean(form.from)} value={form.to} onChange={e=>setForm({...form,to:e.target.value})}/></label><label>Referência, cliente, equipamento, série ou responsável<input type="search" maxLength={100} value={form.search} onChange={e=>setForm({...form,search:e.target.value})}/></label><label>Estado dos apontamentos<select value={form.state} onChange={e=>setForm({...form,state:e.target.value})}><option value="">Todos</option>{Object.entries(workshopStates).map(([v,t])=><option key={v} value={v}>{t}</option>)}</select></label><label>Registos por página<select value={form.per_page} onChange={e=>setForm({...form,per_page:e.target.value})}><option value="25">25</option><option value="100">100</option></select></label></div><button disabled={loading}>Pesquisar</button><button type="button" disabled={loading} onClick={()=>setQuery(q=>({...q,page:1}))}>Actualizar consulta</button></form>
|
||||
{loading&&<p role="status">A consultar a página autorizada…</p>}{error&&<p role="alert">{error}. Não é possível concluir que existem zero ordens ou pendências.</p>}{p&&<><p>{data.opening_period?`Abertura entre ${data.opening_period.from} e ${data.opening_period.to}, ${data.opening_period.zone}; fim exclusivo UTC ${data.opening_period.end_utc_exclusive}.`:'Sem filtro de abertura.'}</p><p role="status">{p.total} ordem(ns) para os filtros · página {p.current_page} de {p.last_page} · leitura {data.observed_at}.</p><div className="table-scroll"><table><thead><tr><th>Ordem e cliente</th><th>Equipamento</th><th>Responsável</th><th>Estado</th><th>Apontamentos actuais</th><th>Tempo em minutos</th><th>Consultar</th></tr></thead><tbody>{p.data.map(r=><WorkshopOrderRow key={r.id} row={r} navigate={navigate}/>)}</tbody></table></div>{p.data.length===0&&<p>Nenhuma ordem corresponde aos filtros e à atribuição autorizada.</p>}<button disabled={loading||p.current_page<=1} onClick={()=>setQuery(q=>({...q,page:q.page-1}))}>Anterior</button><button disabled={loading||p.current_page>=p.last_page} onClick={()=>setQuery(q=>({...q,page:q.page+1}))}>Seguinte</button><button disabled={loading} onClick={exportPage}>Exportar apenas esta página CSV</button></>}
|
||||
<p className="help">Versões substituídas não contam como apontamentos actuais. “Sem verificação favorável” inclui pendentes, recusados e ocorrências reconhecidas; não é apenas uma fila de decisões por tomar. Sem totais de materiais ou custos entre unidades diferentes. A consulta não bloqueia a empresa para confirmação nem altera as ordens.</p></section>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React,{useEffect,useMemo,useState}from'react';
|
||||
import{WorkshopStockOperation}from'./workshop-stock-operation.js';
|
||||
|
||||
const day=value=>new Date(value).toISOString().slice(0,10);
|
||||
const display=value=>new Intl.DateTimeFormat('pt-MZ',{dateStyle:'short',timeStyle:'short',timeZone:'Africa/Maputo'}).format(new Date(value));
|
||||
const local=value=>{const d=new Date(value);return new Date(d.getTime()+120*60000).toISOString().slice(0,16);};
|
||||
const utc=value=>new Date(value+':00+02:00').toISOString();
|
||||
export default function WorkshopScheduleWorkspace({api,context,can}){
|
||||
const now=new Date(),[filters,setFilters]=useState({from:day(now),to:day(new Date(now.getTime()+7*86400000)),state:'',search:'',per_page:25,page:1}),[applied,setApplied]=useState(null),[data,setData]=useState(null),[options,setOptions]=useState(null),[optionQuery,setOptionQuery]=useState({search:'',page:1,per_page:25}),[optionSearch,setOptionSearch]=useState(''),[form,setForm]=useState(null),[preview,setPreview]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[history,setHistory]=useState(null),[historyKey,setHistoryKey]=useState(null);
|
||||
const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),manage=can('workshop.assign',context.unit?.id),locked=busy||command.uncertain;
|
||||
const query=value=>new URLSearchParams(Object.fromEntries(Object.entries(value).filter(([,v])=>v!==''&&v!==null))).toString();
|
||||
async function load(f=applied??filters){setData(await api('/api/workshop-schedule?'+query(f)));}
|
||||
useEffect(()=>{setApplied(filters);let current=true;api('/api/workshop-schedule?'+query(filters)).then(v=>{if(current)setData(v);}).catch(e=>{if(current)setError(e.message);});return()=>{current=false;};},[context.token]);
|
||||
useEffect(()=>{if(!manage)return;let current=true;api('/api/workshop-schedule/options?'+query(optionQuery)).then(v=>{if(current)setOptions(v.orders);}).catch(e=>{if(current)setError(e.message);});return()=>{current=false;};},[context.token,optionQuery,manage]);
|
||||
const start=()=>{setForm({action:'book',booking_key:crypto.randomUUID(),version:0,order_id:'',technician_id:'',starts_at:'',ends_at:'',reason:''});setPreview(null);setNotice('');setError('');};
|
||||
const select=(row,action)=>{setForm({action,booking_key:row.booking_key,version:row.version,order_id:row.order_id,technician_id:row.technician_id,starts_at:local(row.starts_at),ends_at:local(row.ends_at),reason:''});setPreview(null);setError('');};
|
||||
const update=(key,value)=>{setPreview(null);setForm(f=>({...f,[key]:value}));};
|
||||
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input={...form,order_id:Number(form.order_id),technician_id:Number(form.technician_id),starts_at:utc(form.starts_at),ends_at:utc(form.ends_at)};const result=await api('/api/workshop-schedule/preview','POST',input,undefined,crypto.randomUUID());setPreview(result);}catch(e){setError(e.message);}finally{setBusy(false);}}
|
||||
async function confirm(retry=false){setBusy(true);setError('');try{const r=retry?await command.retry():await command.send('/api/workshop-schedule/confirm',{preview:preview.preview},crypto.randomUUID());setNotice(`Agendamento v${r.version} ${r.state==='cancelled'?'cancelado':'confirmado'}${r.replayed?' · recibo recuperado':''}.`);setPreview(null);setForm(null);await load();}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}}
|
||||
async function openHistory(key,page=1){setError('');try{const result=await api('/api/workshop-schedule/'+key+'/history?page='+page);setHistory(result.events);setHistoryKey(key);}catch(e){setError(e.message);}}
|
||||
const reminders={within24h:'Nas próximas24h',in_progress:'Intervalo em curso',elapsed:'Intervalo decorrido — não presume trabalho concluído',planned:'Planeado',cancelled:'Cancelado'};
|
||||
return <section className="panel" data-sgf-operation-uncertain={command.uncertain||preview?'true':undefined}><h2>Agenda e disponibilidade</h2><p>Horários de Moçambique (UTC+02). Liga ordens já existentes ao técnico atribuído e ao equipamento. Agendar não autoriza trabalho nem altera stock, custos ou facturação. Lembretes apenas neste ecrã.</p>{error&&<p role="alert" className="alert error">{error}</p>}{notice&&<p role="status">{notice}</p>}
|
||||
<form onSubmit={e=>{e.preventDefault();setApplied(filters);load(filters).catch(e=>setError(e.message));}}><fieldset disabled={locked}><div className="form-grid"><label>Desde<input type="date" required value={filters.from} onChange={e=>setFilters({...filters,from:e.target.value,page:1})}/></label><label>Até<input type="date" required value={filters.to} onChange={e=>setFilters({...filters,to:e.target.value,page:1})}/></label><label>Ordem ou equipamento<input value={filters.search} onChange={e=>setFilters({...filters,search:e.target.value,page:1})}/></label><label>Estado<select value={filters.state} onChange={e=>setFilters({...filters,state:e.target.value,page:1})}><option value="">Todos</option><option value="scheduled">Agendados</option><option value="cancelled">Cancelados</option></select></label><label>Por página<select value={filters.per_page} onChange={e=>setFilters({...filters,per_page:Number(e.target.value),page:1})}><option value="25">25</option><option value="100">100</option></select></label></div><button>Consultar agenda</button></fieldset></form>
|
||||
{manage&&<button disabled={locked||Boolean(form)} onClick={start}>Novo agendamento</button>}
|
||||
{data&&<><div className="table-scroll"><table><thead><tr><th>Ordem/equipamento</th><th>Técnico</th><th>Intervalo</th><th>Situação</th><th>Acções</th></tr></thead><tbody>{data.bookings.data.map(row=><tr key={row.id}><td>{row.reference}<small>{row.equipment}</small></td><td>{row.technician}</td><td>{display(row.starts_at)}<small>até {display(row.ends_at)}</small></td><td>{reminders[row.reminder]}</td><td><button disabled={locked} onClick={()=>openHistory(row.booking_key)}>Histórico</button>{manage&&row.state==='scheduled'&&<><button disabled={locked||Boolean(form)} onClick={()=>select(row,'reschedule')}>Reagendar</button><button disabled={locked||Boolean(form)} onClick={()=>select(row,'cancel')}>Cancelar</button></>}</td></tr>)}</tbody></table></div>{!data.bookings.data.length&&<p>Sem agendamentos visíveis nos filtros seleccionados.</p>}<div className="actions"><button disabled={locked||data.bookings.current_page<=1} onClick={()=>{const f={...applied,page:data.bookings.current_page-1};setApplied(f);load(f).catch(e=>setError(e.message));}}>Anterior</button><span>Página {data.bookings.current_page}/{data.bookings.last_page}</span><button disabled={locked||data.bookings.current_page>=data.bookings.last_page} onClick={()=>{const f={...applied,page:data.bookings.current_page+1};setApplied(f);load(f).catch(e=>setError(e.message));}}>Seguinte</button></div></>}
|
||||
{form&&<section className="panel"><h3>{form.action==='book'?'Novo agendamento':form.action==='cancel'?'Cancelar agendamento':'Reagendar'}</h3>{form.action==='book'&&<form onSubmit={e=>{e.preventDefault();setOptionQuery({...optionQuery,search:optionSearch,page:1});}}><fieldset disabled={locked||Boolean(preview)}><label>Pesquisar ordem<input value={optionSearch} onChange={e=>setOptionSearch(e.target.value)}/></label><button>Pesquisar</button>{options&&<><button type="button" disabled={options.current_page<=1} onClick={()=>setOptionQuery({...optionQuery,page:options.current_page-1})}>Ordens anteriores</button><button type="button" disabled={options.current_page>=options.last_page} onClick={()=>setOptionQuery({...optionQuery,page:options.current_page+1})}>Ordens seguintes</button></>}</fieldset></form>}
|
||||
<form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}>{form.action==='book'?<label>Ordem<select value={form.order_id} required onChange={e=>{const row=options?.data.find(o=>o.id===Number(e.target.value));setForm({...form,order_id:e.target.value,technician_id:row?.assignment?.technician_id??''});}}><option value="">Escolher ordem…</option>{options?.data.map(o=><option key={o.id} value={o.id} disabled={!o.assignment}>{o.reference} · {o.equipment} · {o.assignment?.name??'atribuir técnico na ordem primeiro'}</option>)}</select></label>:<p>Ordem #{form.order_id} · versão {form.version}</p>}<p>O técnico é o já atribuído na ordem; alterar a agenda não reatribui trabalho.</p><label>Início<input type="datetime-local" required disabled={form.action==='cancel'} value={form.starts_at} onChange={e=>update('starts_at',e.target.value)}/></label><label>Fim<input type="datetime-local" required disabled={form.action==='cancel'} value={form.ends_at} onChange={e=>update('ends_at',e.target.value)}/></label><label>Fundamento<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>update('reason',e.target.value)}/></label><button>Conferir disponibilidade e impacto</button></fieldset></form>
|
||||
{preview&&<div role="status"><h4>Impacto antes da confirmação</h4><p>{preview.impact.order_reference} · {display(preview.impact.starts_at)} até {display(preview.impact.ends_at)}. {preview.impact.state==='cancelled'?'Liberta o intervalo reservado.':'Reserva o técnico e equipamento neste intervalo.'}</p><p>Sem movimento físico, efeito financeiro ou envio de mensagens.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão</button></div>}{command.uncertain&&<button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button>}<button disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar</button>
|
||||
</section>}
|
||||
{history&&<section className="panel"><h3>Histórico do agendamento</h3>{history.data.map(row=><article key={row.id}><strong>Versão {row.version} · {row.state==='cancelled'?'Cancelado':'Agendado'}</strong><p>{display(row.starts_at)} — {display(row.ends_at)}</p><p>Autor #{row.actor_id} · {display(row.created_at)} · {row.reason}</p></article>)}<button disabled={history.current_page<=1||locked} onClick={()=>openHistory(historyKey,history.current_page-1)}>Anterior</button><button disabled={history.current_page>=history.last_page||locked} onClick={()=>openHistory(historyKey,history.current_page+1)}>Seguinte</button></section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {milliQuantity} from './StockReadWorkspace.jsx';
|
||||
import {WorkshopStockOperation} from './workshop-stock-operation.js';
|
||||
|
||||
export function WorkshopStockImpact({impact:p}) {
|
||||
return <p>Físico: {milliQuantity(p.physical_before)} → {milliQuantity(p.physical_after)}. Nova saída: {milliQuantity(p.quantity_milli)}. {['link_existing_delivery','allocate_existing_delivery'].includes(p.mode)&&<>Ligar entrega #{p.existing_movement_id}: {milliQuantity(p.linked_quantity_milli)}, sem novo abatimento. </>}{p.mode==='return_existing_delivery'&&<>Entrada por devolução: {milliQuantity(p.return_quantity_milli)}, da entrega #{p.existing_movement_id}. </>}{['allocate_existing_delivery','return_existing_delivery'].includes(p.mode)&&<>Quantidade não associada: {milliQuantity(p.unallocated_before)} → {milliQuantity(p.unallocated_after)}. </>}Artigo #{p.stock_item_id}, armazém #{p.warehouse_id}, origem versão {p.source_version}.</p>;
|
||||
@@ -10,25 +11,35 @@ export function WorkshopStockClosureImpact({action}) {
|
||||
|
||||
export default function WorkshopStockWorkspace({api,context,can}) {
|
||||
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[page,setPage]=useState(1),[busy,setBusy]=useState(false),[preview,setPreview]=useState(null),[proposalPage,setProposalPage]=useState(1),[status,setStatus]=useState("all");
|
||||
const [uncertain,setUncertain]=useState(false);const operation=useRef(null);if(!operation.current)operation.current=new WorkshopStockOperation(api);
|
||||
const request=useRef(null),generation=useRef(0),unit=context.unit.id;
|
||||
useEffect(()=>{setPreview(null);setBusy(false);request.current=null;},[context.token]);
|
||||
useEffect(()=>{const n=++generation.current;setData(null);setError('');api(`/api/workshop-stock?page=${page}&proposal_page=${proposalPage}&status=${status}`).then(d=>{if(n===generation.current)setData(d);}).catch(e=>{if(n===generation.current)setError(e.message);});return()=>{generation.current++;};},[context.token,reload,page,proposalPage,status]);
|
||||
async function prepare(e,source){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body={...Object.fromEntries(new FormData(e.currentTarget)),entry_id:source.id,version:source.version};body.warehouse_id=Number(body.warehouse_id);
|
||||
const text=JSON.stringify(body);if(request.current?.text!==text)request.current={text,key:crypto.randomUUID()};
|
||||
async function execute(path,body,key,recover=false){
|
||||
if(busy||(!recover&&uncertain))return;const n=generation.current;setBusy(true);setError('');
|
||||
try{const result=recover?await operation.current.retry():await operation.current.send(path,body,key);if(n===generation.current){setPreview(result.impact?result:null);setReload(v=>v+1);}}
|
||||
catch(e){if(n===generation.current)setError(operation.current.uncertain?'Resultado incerto. Recupere a mesma operação antes de continuar.':e.message);}
|
||||
finally{if(n===generation.current){setUncertain(operation.current.uncertain);setBusy(false);}}
|
||||
}
|
||||
async function prepare(e,source){e.preventDefault();if(busy||uncertain)return;const body={...Object.fromEntries(new FormData(e.currentTarget)),entry_id:source.id,version:source.version};body.warehouse_id=Number(body.warehouse_id);
|
||||
const allocation=body.mode==='allocate_existing_delivery',returning=body.mode==='return_existing_delivery',delivery=returning||allocation||body.mode==='link_existing_delivery';delete body.mode;if(delivery)body.movement_id=Number(body.movement_id);else delete body.movement_id;
|
||||
try{const result=await api(returning?'/api/workshop-stock/prepare-return':allocation?'/api/workshop-stock/prepare-allocation':delivery?'/api/workshop-stock/prepare-delivery':'/api/workshop-stock/prepare','POST',body,undefined,request.current.key);if(n===generation.current){setPreview(result);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
|
||||
async function confirm(e,p){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body=Object.fromEntries(new FormData(e.currentTarget));if('received_in_warehouse' in body)body.received_in_warehouse=body.received_in_warehouse==='on';try{await api(`/api/workshop-stock/${p.id}/confirm`,'POST',body);if(n===generation.current){setPreview(null);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
|
||||
async function close(e,p,action){e.preventDefault();const n=generation.current;setBusy(true);setError('');const body={...Object.fromEntries(new FormData(e.currentTarget)),state_version:p.state_version,source_hash:p.source_hash};try{await api(`/api/workshop-stock/${p.id}/${action}`,'POST',body);if(n===generation.current){setPreview(null);setReload(v=>v+1);}}catch(e){if(n===generation.current)setError(e.message);}finally{if(n===generation.current)setBusy(false);}}
|
||||
const path=returning?'/api/workshop-stock/prepare-return':allocation?'/api/workshop-stock/prepare-allocation':delivery?'/api/workshop-stock/prepare-delivery':'/api/workshop-stock/prepare';
|
||||
const text=JSON.stringify([path,body]);if(request.current?.text!==text)request.current={text,key:crypto.randomUUID()};return execute(path,body,request.current.key);
|
||||
}
|
||||
async function confirm(e,p){e.preventDefault();if(busy||uncertain)return;const body=Object.fromEntries(new FormData(e.currentTarget));if('received_in_warehouse' in body)body.received_in_warehouse=body.received_in_warehouse==='on';return execute(`/api/workshop-stock/${p.id}/confirm`,body,crypto.randomUUID());}
|
||||
async function close(e,p,action){e.preventDefault();if(busy||uncertain)return;return execute(`/api/workshop-stock/${p.id}/${action}`,{...Object.fromEntries(new FormData(e.currentTarget)),state_version:p.state_version,source_hash:p.source_hash},crypto.randomUUID());}
|
||||
const closureForm=(p,action)=><details><summary>{action==='withdraw'?'Retirar proposta própria':'Recusar proposta'}</summary><WorkshopStockClosureImpact action={action}/><form onSubmit={e=>close(e,p,action)}><label>Fundamento do encerramento<textarea name="reason" minLength={10} maxLength={1000} required/></label><button disabled={busy}>{action==='withdraw'?'Confirmar retirada sem movimento':'Confirmar recusa sem movimento'}</button></form></details>;
|
||||
const impact=p=><WorkshopStockImpact impact={p.impact}/>;
|
||||
return <section className="panel"><h2>Conferência de consumo de material</h2><p>Uma origem verificada não provoca saída automática. Entregas já abatidas e correcções exigem reconciliação própria.</p>
|
||||
return <section className="panel" data-sgf-operation-uncertain={uncertain?"true":undefined}><h2>Conferência de consumo de material</h2><p>Uma origem verificada não provoca saída automática. Entregas já abatidas e correcções exigem reconciliação própria.</p>
|
||||
{error&&<p role="alert">{error}</p>}{!data&&!error&&<p role="status">A consultar origens autorizadas…</p>}
|
||||
<button disabled={busy} onClick={()=>setReload(n=>n+1)}>Actualizar</button>
|
||||
{uncertain&&<button disabled={busy} onClick={()=>execute(null,null,null,true)}>Recuperar a mesma operação</button>}<fieldset disabled={busy||uncertain} style={{border:0,padding:0}}><button disabled={busy} onClick={()=>setReload(n=>n+1)}>Actualizar</button>
|
||||
{preview&&<div role="status"><h3>Proposta #{preview.id}</h3>{impact(preview)}<p>{preview.reason} · Evidência: {preview.evidence}</p><p>{preview.state&&preview.state!=='pending'?'Proposta já encerrada; a prévia é histórica.':'A confirmação é individual, por outra identidade autorizada.'}</p></div>}
|
||||
{data&&<><p>{data.notice}</p><h3>Origens verificadas</h3>{data.sources.map(source=>{const d=typeof source.data==='string'?JSON.parse(source.data):source.data;return <article className="panel" key={source.id}><h4>Ordem #{source.order_id} · Origem #{source.id} / v{source.version}</h4><p>{d.resource} · {source.quantity} {d.unit} · {d.event} · {source.origin}</p>
|
||||
{source.movement_id?<p>Esta origem já tem o movimento #{source.movement_id}. Uma nova versão exige conferência/correcção física separada, sem novo abatimento automático.</p>:can('workshop_stock.prepare',unit)&&<form onSubmit={e=>prepare(e,source)}><label>Tratamento<select name="mode" required defaultValue=""><option value="" disabled>Seleccione individualmente</option>{d.event!=="returned"&&<><option value="new_issue">Registar saída nova</option><option value="link_existing_delivery">Ligar saída já registada, sem novo abatimento</option><option value="allocate_existing_delivery">Associar este consumo à parte restante de uma entrega</option></>}{d.event==="returned"&&<option value="return_existing_delivery">Confirmar entrada da devolução não consumida</option>}</select></label><label>Movimento da entrega existente (apenas para ligação)<input name="movement_id" type="number" min="1"/></label><label>Armazém autorizado<input name="warehouse_id" type="number" min="1" required/></label><label>Fundamento<textarea name="reason" minLength={10} maxLength={1000} required/></label><label>Evidência da entrega e do tratamento físico<input name="evidence" minLength={3} maxLength={500} required/></label><button disabled={busy}>Conferir impacto</button></form>}</article>;})}
|
||||
{source.movement_id?<p>Esta origem já tem o movimento #{source.movement_id}. Uma nova versão exige conferência/correcção física separada, sem novo abatimento automático.</p>:can('workshop_stock.prepare',unit)&&<form onSubmit={e=>prepare(e,source)}><label>Tratamento<select name="mode" required defaultValue=""><option value="" disabled>Seleccione individualmente</option>{d.event!=="returned"&&<><option value="new_issue">Registar saída nova</option><option value="link_existing_delivery">Ligar saída já registada, sem novo abatimento</option><option value="allocate_existing_delivery">Associar este consumo à parte restante de uma entrega</option></>}{d.event==="returned"&&<option value="return_existing_delivery">Confirmar entrada da devolução não consumida</option>}</select></label><label>Entrega existente (apenas para ligação/devolução){source.deliveries?<select name="movement_id" defaultValue=""><option value="">Escolha a entrega individual</option>{source.deliveries.map(m=><option key={m.id} value={m.id}>{m.reference} · {milliQuantity(-m.quantity_milli)} UN</option>)}</select>:<input name="movement_id" type="number" min="1"/>}</label><label>Armazém autorizado{source.warehouses?<select name="warehouse_id" required defaultValue=""><option value="">Escolha o armazém</option>{source.warehouses.map(w=><option key={w.id} value={w.id}>{w.code} · {w.name}</option>)}</select>:<input name="warehouse_id" type="number" min="1" required/>}</label><label>Fundamento<textarea name="reason" minLength={10} maxLength={1000} required/></label><label>Evidência da entrega e do tratamento físico<input name="evidence" minLength={3} maxLength={500} required/></label><button disabled={busy}>Conferir impacto</button></form>}</article>;})}
|
||||
<button disabled={busy||page<=1} onClick={()=>setPage(p=>p-1)}>Anterior</button><button disabled={busy||page>=data.pagination.last_page} onClick={()=>setPage(p=>p+1)}>Seguinte</button>
|
||||
<h3>Histórico de propostas da unidade</h3><label>Estado<select value={status} onChange={e=>{setStatus(e.target.value);setProposalPage(1);}}><option value="all">Todas</option><option value="pending">Pendentes</option><option value="confirmed">Confirmadas</option>{data.closure_supported&&<><option value="withdrawn">Retiradas</option><option value="rejected">Recusadas</option></>}</select></label>{data.proposals.map(p=><article className="panel" key={p.id}><h4>Proposta #{p.id} · Origem #{p.entry_id} / v{p.entry_version}</h4>{impact(p)}<p>Preparador #{p.preparer_id} · {p.created_at}</p><p>{p.reason} · Evidência: {p.evidence}</p>
|
||||
{p.closure?<p>Prévia arquivada, não executada. {p.state==='withdrawn'?'Retirada':'Recusada'} por #{p.closure.actor_id} em {p.closure.created_at}: {p.closure.reason}.</p>:p.movement_id?<p>Tratamento já registado: movimento #{p.movement_id}.</p>:<>{p.can_review&&can('workshop_stock.confirm',unit)?<><form onSubmit={e=>confirm(e,p)}>{p.impact.mode==="return_existing_delivery"&&<label><input type="checkbox" name="received_in_warehouse" required/>Confirmo a recepção física desta quantidade no armazém indicado</label>}<label>Fundamento da confirmação<textarea name="reason" minLength={10} maxLength={1000} required/></label><p>O servidor exige outra identidade e revalida origem, mapa, direitos e livro antes de gravar.</p><button disabled={busy}>Confirmar tratamento conferido</button></form>{data.closure_supported&&closureForm(p,'reject')}</>:<p>Aguarda confirmação independente.</p>}{p.can_withdraw&&can('workshop_stock.prepare',unit)&&closureForm(p,'withdraw')}</>}</article>)}<button disabled={busy||proposalPage<=1} onClick={()=>setProposalPage(p=>p-1)}>Propostas anteriores</button><button disabled={busy||proposalPage>=data.proposal_pagination.last_page} onClick={()=>setProposalPage(p=>p+1)}>Propostas seguintes</button></>}
|
||||
</section>;
|
||||
</fieldset></section>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {visibleWorkshopSections} from './workshop-menu.js';
|
||||
import {EquipmentPicker}from'./WorkshopEquipmentWorkspace.jsx';
|
||||
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'};
|
||||
@@ -7,6 +8,7 @@ const iso=v=>new Date(v).toISOString();
|
||||
export default function WorkshopWorkspace({context,can,api,busy,initialOrderId=null,navigate}) {
|
||||
const [data,setData]=useState(null),[orderId,setOrderId]=useState(initialOrderId??''),[error,setError]=useState(''),[working,setWorking]=useState(false),[taskId,setTaskId]=useState('');
|
||||
const attempt=useRef(null);
|
||||
const[equipmentMode,setEquipmentMode]=useState('new'),[equipmentClient,setEquipmentClient]=useState('');
|
||||
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]);
|
||||
@@ -35,9 +37,9 @@ export default function WorkshopWorkspace({context,can,api,busy,initialOrderId=n
|
||||
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 <>
|
||||
{navigate&&<nav className="billing-section-tabs" aria-label="Submenus de oficinas">{visibleWorkshopSections(can,context.unit?.id??null).map(s=><button key={s.id} aria-current={s.id==='workshop'?'page':undefined} onClick={()=>{if(s.id!=='workshop')navigate(s.id);}}>{s.label}</button>)}</nav>}
|
||||
<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>
|
||||
<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>{context.single_installation?'Stock, custos, facturação e recebimentos usam percursos próprios ligados à origem, com decisões explícitas.':'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>}
|
||||
{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" required value={equipmentClient} onChange={e=>setEquipmentClient(e.target.value)}><option value="">Escolher cliente…</option>{data.clients.map(c=><option key={c.id} value={c.id}>#{c.id} · {c.name}</option>)}</select></label><>{context.workshop_catalogue_enabled&&<label>Origem do equipamento<select value={equipmentMode} onChange={e=>setEquipmentMode(e.target.value)}><option value="new">Registar novo equipamento nesta ordem</option><option value="existing">Reutilizar equipamento já identificado</option></select></label>}{context.workshop_catalogue_enabled&&equipmentMode==="existing"?<EquipmentPicker api={api} context={context} clientId={equipmentClient} disabled={disabled}/>:<><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>}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import React from 'react';
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {moduleGuides,overviewSources,overviewPage} from './workspace-overview.js';
|
||||
|
||||
export default function WorkspaceHome({context}) {
|
||||
return <>
|
||||
<div className="page-title"><h1>Início</h1></div>
|
||||
{!context.unit&&<p className="alert home-notice" role="status">Seleccione uma unidade para consultar os percursos de trabalho dessa unidade.</p>}
|
||||
</>;
|
||||
export function OverviewRecords({source,result,navigate}){
|
||||
return <>{result.facts?.length>0&&<dl className="overview-definitions">{result.facts.map(f=><div key={f.label}><dt>{f.label}</dt><dd>{f.value}</dd></div>)}</dl>}<p role="status">{result.total} registo(s) nesta fonte autorizada · página {result.page} de {result.last}.{result.observed&&` Referência da fonte: ${result.observed}.`}</p><div className="table-scroll"><table><thead><tr><th>Referência e descrição</th><th>Estado ou entidade</th><th>Valor ou identificação</th><th>Evidência</th></tr></thead><tbody>{result.rows.map((r,i)=><tr key={i}><td><strong>{r.reference}</strong><small>{r.detail}</small></td><td>{r.state}</td><td>{r.value}</td><td>{r.evidence}</td></tr>)}</tbody></table></div>{!result.rows.length&&<p>Nenhum registo nesta página e fonte autorizadas.</p>}<p>{source.href?<a href={source.href}>Abrir consulta completa, documentos e histórico</a>:<button onClick={()=>navigate(source.target)}>Abrir consulta completa, documentos e histórico</button>}</p></>;
|
||||
}
|
||||
export default function WorkspaceHome({context,groups=[],can=()=>false,api,navigate=()=>{},openPage=()=>{}}) {
|
||||
const sources=overviewSources(context,can),guide=moduleGuides[context.module_id];
|
||||
const [selection,setSelection]=useState(''),[result,setResult]=useState(null),[error,setError]=useState(''),[loading,setLoading]=useState(false);const generation=useRef(0);
|
||||
useEffect(()=>{generation.current++;setSelection('');setResult(null);setError('');setLoading(false);return()=>{generation.current++;};},[context.token]);
|
||||
const source=sources.find(s=>s.id===selection);
|
||||
async function load(s,page=1){const current=++generation.current;setSelection(s.id);setResult(null);setError('');setLoading(true);try{const r=await api(s.path+(s.path.includes('?')?'&':'?')+'page='+page);const value=overviewPage(s,r);if(current===generation.current)setResult(value);}catch(e){if(current===generation.current)setError(e.message);}finally{if(current===generation.current)setLoading(false);}}
|
||||
const activeGroups=groups.filter(g=>!['start','admin'].includes(g.id));
|
||||
return <div className="workspace-overview">
|
||||
<div className="page-title"><p className="eyebrow">ÁREA DE TRABALHO</p><h1>{guide?guide.label:'Início do SGF'}</h1><p>{guide?.purpose??'Escolha um módulo e uma função autorizada para trabalhar com os seus documentos.'}</p><p>{context.company.name}{context.unit&&` / ${context.unit.name}`}. Informação limitada ao contexto e à função actuais.</p></div>
|
||||
{!context.unit&&<p className="alert home-notice" role="status">Seleccione uma unidade para consultar os percursos de trabalho dessa unidade.</p>}
|
||||
{guide&&<section className="panel"><h2>Do primeiro registo à evidência final</h2><ol className="overview-steps">{guide.steps.map(s=><li key={s}>{s}</li>)}</ol><dl className="overview-definitions"><div><dt>O que comprova o percurso</dt><dd>{guide.evidence}</dd></div><div><dt>Como interpretar a informação</dt><dd>{guide.boundary}</dd></div><div><dt>Ligação à etapa seguinte</dt><dd>{guide.next}</dd></div></dl></section>}
|
||||
{!!sources.length&&<section className="panel"><h2>Informação existente neste módulo</h2><p>Escolha a fonte a consultar. A leitura não prepara nem repete operações; cada página traz até25 registos (requisições:15). Os valores são apresentados individualmente.</p><div className="actions">{sources.map(s=><button key={s.id} aria-pressed={s.id===selection} disabled={loading} onClick={()=>load(s)}>{s.label}</button>)}</div>{loading&&<p role="status">A consultar a fonte autorizada…</p>}{error&&<div role="alert"><p>{error}</p><p>Não é possível concluir que existem zero registos ou saldo zero.</p><button onClick={()=>source&&load(source)}>Tentar novamente</button></div>}{source&&result&&<><h3>{source.label}</h3><OverviewRecords source={source} result={result} navigate={navigate}/><div className="actions"><button disabled={loading||result.page<=1} onClick={()=>load(source,result.page-1)}>Anterior</button><button disabled={loading||result.page>=result.last} onClick={()=>load(source,result.page+1)}>Seguinte</button><button disabled={loading} onClick={()=>load(source)}>Actualizar a partir da primeira página</button></div><p className="help">Leitura actual por página, sem fotografia global entre páginas. Eventos de preparação/revisão não são movimentos financeiros; não se somam os valores desta lista.</p></>}</section>}
|
||||
{activeGroups.length>0&&<section className="panel"><h2>Funções disponíveis para o seu trabalho</h2><div className="overview-grid">{activeGroups.map(g=><article key={g.id}><h3>{g.label}</h3>{g.items.map(i=><button key={i.id} onClick={()=>navigate(i.id)}>{i.label} →</button>)}</article>)}</div></section>}
|
||||
<section className="panel"><h2>Continuidade entre módulos</h2><p>A mudança de módulo é explícita e conserva os controlos de acesso. Cada entrada abaixo oferece apenas as funções já autorizadas para a sua conta.</p><div className="overview-grid">{(context.modules??[]).map(m=>{const g=moduleGuides[m.id];return <article key={m.id}><h3>{m.label}</h3><p>{g?.purpose}</p><p>{g?.next}</p><button disabled={m.id===context.module_id} onClick={()=>openPage(m.id,'home')}>{m.id===context.module_id?'Módulo actual':'Abrir início de '+m.label}</button></article>;})}</div></section>
|
||||
{context.single_installation&&<p className="help">Instalação local com dados fictícios. As consultas mostram os registos existentes; mapas fiscais internos e indicadores de ensaio não comprovam conformidade legal ou operação real.</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import {visibleWorkshopSections} from './workshop-menu.js';
|
||||
import {visibleStockSections} from './stock-menu.js';
|
||||
import {visibleAccountingSections} from './accounting-menu.js';
|
||||
|
||||
export function navigationGroups(can, unit) {
|
||||
export function navigationGroups(can, unit, capabilities={}) {
|
||||
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:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit),item('supplier_payments','Pagamentos e afectações — ensaio','supplier_payables.view',unit)]},
|
||||
{id:'stock',label:'Stock',status:'Em preparação',items:visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true}))},
|
||||
{id:'stock',label:'Stock',status:'Em preparação',items:[...visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.stockCatalogue?[item('stock_catalogue','Artigos e revisão do catálogo','stock.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:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))},
|
||||
{id:'billing_support',label:'Configuração documental',items:[{id:'documents',label:'Preparação documental',visible:can('issuer_documents.view')||can('client_documents.view')||can('draft_documents.view',unit)},item('service_invoice_setup','Séries e emissão 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:'treasury',label:'Fornecedores',items:[...(capabilities.payablePositions?[item('payable_positions','Posições de fornecedores','supplier_payables.view',unit),item('payable_aging','Mapa de antiguidade — fornecedores','supplier_payables.view',unit)]:[]),item('native_payables','Obrigações, pagamentos e afectações','supplier_payables.view',unit)]},
|
||||
{id:'receivables',label:'Recebimentos e Caixa',items:[item('receivable_positions','Posições de clientes e vencimentos','service_receivables.view',unit),item('service_receivables','Recebimentos, afectações e reconciliação de teste','service_receivables.view',unit),item('financial_indicators','Critérios e indicadores QA','financial_analysis.view',unit),item('bank_reconciliation','Conferência de extracto QA','bank_reconciliation.view',unit),item('cash','Caixa — abertura, despesas e fecho QA','cash.view',unit)]},
|
||||
{id:'workshop',label:'Oficinas',items:[...visibleWorkshopSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true})),item('workshop_billing','Oficina → rascunho','workshop_billing.view',unit),item('workshop_corrections','Correcção e cancelamento comercial','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:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view')]},
|
||||
{id:'accounting',label:'Contabilidade e fiscalidade',items:[...visibleAccountingSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true})),...(capabilities.internalStatements?[item('internal_statements','Demonstrações internas parciais QA','general_ledger.view',unit)]:[]),item('catalogue_accounting','Classificação contabilística','catalogue_accounting.view'),item('service_fiscal','Perfis fiscais do serviço','service_fiscal.view'),item('fiscal_iva','Livro e mapa interno de IVA QA','fiscal_assessments.view',unit),item('fiscal_rules','Regras fiscais versionadas QA','fiscal_rules.view',unit),...(capabilities.fiscalObligations?[item('fiscal_obligations','Obrigações e prazos administrativos QA','fiscal_obligations.view',unit)]:[])]},
|
||||
...(capabilities.remunerations?[{id:'human_resources',label:'Recursos Humanos',items:[item('remunerations','Cadastro e remunerações internas QA','human_resources.remunerations.view',unit),...(capabilities.personnel?[{id:'hr_personnel',label:'Contratos, assiduidade e férias',visible:['contracts','attendance','leave'].every(a=>can('human_resources.'+a+'.view',unit))}]:[])]}]:[]),
|
||||
{id:'admin',label:'Administração',items:[item('company','Empresa','company.view'),{id:'units',label:'Unidades autorizadas',visible:can('company.view')},item('module_records','Associações de módulos','access.manage'),item('access','Acessos','access.manage')]},
|
||||
].map(g=>({...g,items:g.items.filter(i=>i.visible)})).filter(g=>g.items.length);
|
||||
}
|
||||
|
||||
+47
-17
@@ -7,7 +7,7 @@ import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
|
||||
import DocumentPreparationWorkspace from './DocumentPreparationWorkspace.jsx';
|
||||
import { DocumentPreviewConfiguration } from './DocumentPreview.jsx';
|
||||
import CatalogueAccountingWorkspace from './CatalogueAccountingWorkspace.jsx';
|
||||
import ServiceFiscalWorkspace from './ServiceFiscalWorkspace.jsx';
|
||||
const ServiceFiscalWorkspace=React.lazy(()=>import('./ServiceFiscalWorkspace.jsx'));
|
||||
import ServiceInvoiceWorkspace from './ServiceInvoiceWorkspace.jsx';
|
||||
import ServiceReceivablesWorkspace from './ServiceReceivablesWorkspace.jsx';
|
||||
import WorkshopWorkspace from './WorkshopWorkspace.jsx';
|
||||
@@ -16,11 +16,17 @@ import IndustrialBudgetsWorkspace from './IndustrialBudgetsWorkspace.jsx';
|
||||
import PurchasingBudgetWorkspace from './PurchasingBudgetWorkspace.jsx';
|
||||
import IndustrialCostsWorkspace from './IndustrialCostsWorkspace.jsx';
|
||||
import {navigationGroups} from './WorkspaceNavigation.jsx';
|
||||
import ModuleNavigation, {moduleGroups} from './ModuleNavigation.jsx';
|
||||
import ModuleNavigation, {moduleGroups,moduleFunctionAllowed} from './ModuleNavigation.jsx';
|
||||
import {ContextTransition} from './context-transition.js';
|
||||
import ModuleRecordsWorkspace from './ModuleRecordsWorkspace.jsx';
|
||||
import ContextTransitionDialog from './ContextTransitionDialog.jsx';
|
||||
import WorkspaceHome from './WorkspaceHome.jsx';
|
||||
const PersonnelWorkspace=React.lazy(()=>import('./PersonnelWorkspace.jsx'));
|
||||
const RemunerationsWorkspace=React.lazy(()=>import('./RemunerationsWorkspace.jsx'));
|
||||
const InternalStatementsWorkspace=React.lazy(()=>import('./InternalStatementsWorkspace.jsx'));
|
||||
const FiscalObligationsWorkspace=React.lazy(()=>import('./FiscalObligationsWorkspace.jsx'));
|
||||
const PayablePositionsWorkspace=React.lazy(()=>import('./PayablePositionsWorkspace.jsx'));
|
||||
const ReceivablePositionsWorkspace=React.lazy(()=>import('./ReceivablePositionsWorkspace.jsx'));
|
||||
const WorkspaceHome=React.lazy(()=>import('./WorkspaceHome.jsx'));
|
||||
import WorkshopBillingWorkspace from './WorkshopBillingWorkspace.jsx';
|
||||
import WorkshopCorrectionsWorkspace from './WorkshopCorrectionsWorkspace.jsx';
|
||||
import BillingMenuWorkspace from './BillingMenuWorkspace.jsx';
|
||||
@@ -33,6 +39,8 @@ import {stockSections} from './stock-menu.js';
|
||||
import AccountingMenuWorkspace from './AccountingMenuWorkspace.jsx';
|
||||
import {accountingSections} from './accounting-menu.js';
|
||||
import PurchasingHome from './PurchasingHome.jsx';
|
||||
import {presentationLanding} from './presentation-landing.js';
|
||||
const CommercialQuotesWorkspace=React.lazy(()=>import('./CommercialQuotesWorkspace.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' };
|
||||
|
||||
@@ -50,12 +58,12 @@ function App() {
|
||||
const currentToken = useRef(context?.token); currentToken.current = context?.token;
|
||||
const mountedToken = useRef(context?.token);
|
||||
useEffect(() => { if(mountedToken.current !== context?.token) { transitions.current.discard(); setASelection(null); setPage('home'); mountedToken.current=context?.token; } }, [context?.token]);
|
||||
useEffect(() => { const leave=e=>{transitions.current.prune(); if(transitions.current.dirty.size||transitions.current.pending){e.preventDefault();e.returnValue='';}}; window.addEventListener('beforeunload',leave); return ()=>window.removeEventListener('beforeunload',leave); }, []);
|
||||
function transition(action) { transitions.current.prune(); if(transitions.current.pending||busy||transitionBusy) {setError('Aguarde a conclusão da operação antes de mudar de contexto.');return;} if(transitions.current.dirty.size) setPendingTransition(()=>action); else action(); }
|
||||
useEffect(() => { const leave=e=>{transitions.current.prune(); if(transitions.current.dirty.size||transitions.current.pending||document.querySelector('[data-sgf-operation-uncertain="true"]')){e.preventDefault();e.returnValue='';}}; window.addEventListener('beforeunload',leave); return ()=>window.removeEventListener('beforeunload',leave); }, []);
|
||||
function transition(action) { transitions.current.prune(); if(transitions.current.pending||busy||transitionBusy||document.querySelector('[data-sgf-operation-uncertain="true"]')) {setError('Aguarde a conclusão da operação antes de mudar de contexto.');return;} if(transitions.current.dirty.size) setPendingTransition(()=>action); else action(); }
|
||||
async function resolveTransition(save) { setTransitionBusy(true);setError(''); try { if(save) await transitions.current.save(); else transitions.current.discard(); const action=pendingTransition;setPendingTransition(null); await action(); } catch(e){setError(e.message);} finally{setTransitionBusy(false);} }
|
||||
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
|
||||
|
||||
const allGroups = navigationGroups(can, context?.unit?.id ?? null);
|
||||
const allGroups = navigationGroups(can, context?.unit?.id ?? null,{personnel:context?.hr_personnel_enabled===true,remunerations:context?.remunerations_enabled===true,stockCatalogue:context?.stock?.catalogue===true,payablePositions:context?.single_installation===true,fiscalObligations:context?.fiscal_obligations_enabled===true,internalStatements:context?.internal_statements_enabled===true});
|
||||
const groups = [...allGroups.filter(g=>['start','admin'].includes(g.id)), ...moduleGroups(allGroups,context?.module_id)];
|
||||
const navigate = (id,selection=null) => transition(async () => {
|
||||
setError('');
|
||||
@@ -69,7 +77,13 @@ function App() {
|
||||
}
|
||||
transitions.current.discard();setASelection(selection);setPage(id);
|
||||
});
|
||||
const openModulePage=(module_id,id)=>transition(async()=>{
|
||||
if(!moduleFunctionAllowed(context,allGroups,module_id,id)){setError('Função sem módulo ou permissão autorizados.');return;}
|
||||
setError('');try{if(context.module_id!==module_id){const next=await api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id});mountedToken.current=next.context.token;currentToken.current=next.context.token;setState(next);}transitions.current.discard();setASelection(null);setPage(id);}catch(e){setError(e.message);}
|
||||
});
|
||||
useEffect(() => { if (!groups.some(g => g.items.some(i => i.id === page))) setPage('home'); }, [context?.token, JSON.stringify(context?.grants), page]);
|
||||
const initialLanding=useRef(false);
|
||||
useEffect(()=>{if(initialLanding.current||!context?.token)return;initialLanding.current=true;if(!context.single_installation)return;const url=new URL(window.location.href),start=url.searchParams.get('start');if(!start)return;const target=presentationLanding(context,groups,start);if(target)setPage(target);else setError('Entrada sem página ou permissão autorizadas. Escolha um percurso no módulo actual.');url.searchParams.delete('start');window.history.replaceState(null,'',url.pathname+url.search+url.hash);},[context?.token]);
|
||||
|
||||
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
|
||||
const execute = async () => {
|
||||
@@ -78,7 +92,7 @@ function App() {
|
||||
if (!response.ok) {
|
||||
if ([401,403,409].includes(response.status)) { await refresh(); }
|
||||
const message = response.status === 419 ? 'A sessão expirou. Actualize a página e entre novamente.' : Object.values(result.errors || {}).flat().join(' ') || result.message || 'Não foi possível concluir a operação.';
|
||||
throw new Error(message);
|
||||
const error=new Error(message);error.status=response.status;throw error;
|
||||
}
|
||||
if(token && token !== currentToken.current) throw new Error('Resposta de um contexto anterior ignorada.');
|
||||
return result;
|
||||
@@ -114,21 +128,24 @@ 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" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} changeModule={module_id=>transition(async()=>{await perform(()=>api('/api/context','POST',{tenant_id:context.company.tenant_id,company_id:context.company.id,unit_id:context.unit?.id??null,module_id}),'Módulo alterado.');})} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></header><main>
|
||||
return <div className="shell" onSubmitCapture={e=>{transitions.current.submitting=e.target;}} onInputCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onChangeCapture={e=>e.target.closest('main')&&!e.target.closest('.context-card')&&transitions.current.changed(e.target.closest('form')||e.target.closest('section')||e.target)} onClickCapture={e=>{transitions.current.submitting=e.target.closest('form')||e.target.closest('section');const a=e.target.closest('a[href]');if(a&&a.origin===location.origin){e.preventDefault();transition(()=>{location.href=a.href;});}}}><ModuleNavigation groups={allGroups} context={context} page={page} navigate={navigate} openPage={openModulePage} user={state.user} busy={busy||transitionBusy} logout={()=>transition(()=>perform(()=>api('/api/logout','POST'),'Sessão terminada.'))} />{pendingTransition&&<ContextTransitionDialog busy={transitionBusy} error={error} onSave={()=>resolveTransition(true)} onDiscard={()=>resolveTransition(false)} onCancel={()=>setPendingTransition(null)}/>}<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><div className="workspace-safety" role="status">Ensaios e rascunhos nos percursos identificados · Operações reais de custeio, oficinas, recebimentos e emissão bloqueadas.</div></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}:${page}`}>
|
||||
{page !== 'home' && page !== 'module_records' && context.module_id && <p className="help" role="status">Cadastros e rascunhos: apenas associações explícitas ao módulo activo. Dados anteriores sem associação estão preservados e pendentes de conferência pelo responsável. {can('access.manage')&&<button onClick={()=>navigate('module_records')}>Associações de módulos</button>}</p>}
|
||||
{page === 'home' && <WorkspaceHome context={context} />}
|
||||
{page === 'home' && <React.Suspense fallback={<p role="status">A abrir início do módulo…</p>}><WorkspaceHome context={context} groups={groups} can={can} api={api} navigate={navigate} openPage={openModulePage}/></React.Suspense>}
|
||||
{page==='stock_catalogue'&&context.stock?.catalogue===true&&<section className="panel"><h1>Artigos de Stock</h1><p>Pesquisar o catálogo, preparar um novo artigo e submeter a revisão distinta. Artigos anteriores permanecem só para consulta; aprovar não cria saldo inicial ou movimento.</p><a href="/stock/catalogue">Abrir cadastro, ficha e histórico de artigos</a></section>}
|
||||
{page === 'module_records' && <ModuleRecordsWorkspace context={context} can={can} api={api}/>}
|
||||
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} />}
|
||||
{page === 'workshop_billing' && <WorkshopBillingWorkspace context={context} can={can} api={api} busy={busy} navigate={navigate} />}
|
||||
{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.')} /><GeographyLink enabled={context.geography_enabled} kind="company" id={context.company.id}/>{!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>{context.unit&&<GeographyLink enabled={context.geography_enabled} kind="unit" id={context.unit.id}/>}{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} initialCreate={aSelection?.area === page && aSelection.create} navigate={(area,id) => navigate(area,{area,id})} />}
|
||||
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
|
||||
{billingSections.some(s=>s.id===page)&&page!=='billing_drafts'&&!(page==='billing_quotes'&&context.commercial_quotes_enabled===true)&&<BillingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} api={api} navigate={(area,id=null,create=false)=>navigate(area,{area,id,create})}/>}
|
||||
{stockSections.some(s=>s.id===page)&&<StockMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate} api={api} openReceiptOrigin={openStockReceiptOrigin}/>}
|
||||
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate}/>}
|
||||
{accountingSections.some(s=>s.id===page)&&<AccountingMenuWorkspace key={JSON.stringify(context.grants)} section={page} context={context} can={can} navigate={navigate} api={api}/>}
|
||||
{page==='native_payables'&&<section className="panel"><h1>Financeiro · fornecedores</h1><p>Consultar a obrigação original de Compras, preparar factos de pagamento, conferir afectações e reversões por identidades distintas. Sem outra dívida ou execução de dinheiro real.</p><a href="/finance/payables">Abrir o percurso financeiro integrado</a></section>}
|
||||
{page==='cash'&&<section className="panel"><h1>Financeiro · Caixa</h1>{context.cash_enabled?<><p>Abertura e contagem explícitas, classificação individual de um recebimento efectivo de teste, despesa fictícia e revisão distinta. Sem dinheiro real, ajuste automático ou reabertura.</p><a href="/finance/cash">Abrir Caixa QA, extracto e histórico</a></>:<p>O percurso de Caixa ainda não está disponibilizado neste contexto.</p>}</section>}
|
||||
{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} initialOrderId={aSelection?.area==='workshop'?aSelection.id:null} navigate={id=>navigate(id)} />}
|
||||
@@ -137,16 +154,27 @@ function App() {
|
||||
{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 === 'purchasing_home' && <PurchasingHome context={context} can={can} navigate={navigate}/>}
|
||||
{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 === 'supplier_payments' && <section className="panel"><h1>Pagamentos e afectações a fornecedores</h1><p>Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.</p><a href="/supplier-payments">Abrir pagamentos e afectações sintéticas</a></section>}
|
||||
{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>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e abrir a obrigação original</button>:<a href="/supplier-payables">Abrir preparação e consulta de obrigações</a>}</section>}
|
||||
{page === 'supplier_payments' && <section className="panel"><h1>Pagamentos e afectações a fornecedores</h1><p>Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.</p>{context.single_installation?<button onClick={()=>navigate('native_payables')}>Escolher Financeiro e continuar pagamentos e afectações</button>:<a href="/supplier-payments">Abrir pagamentos e afectações sintéticas</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==='internal_statements'&&<React.Suspense fallback={<p>A abrir demonstrações internas…</p>}><InternalStatementsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
|
||||
{page==='hr_personnel'&&<React.Suspense fallback={<p>A abrir contratos, assiduidade e férias…</p>}><PersonnelWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
|
||||
{page==='remunerations'&&<React.Suspense fallback={<p>A abrir Recursos Humanos…</p>}><RemunerationsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
|
||||
{page==='fiscal_obligations'&&<React.Suspense fallback={<p>A abrir obrigações…</p>}><FiscalObligationsWorkspace key={context.token} context={context} api={api}/></React.Suspense>}
|
||||
{['payable_positions','payable_aging'].includes(page)&&<React.Suspense fallback={<p>A abrir posições de fornecedores…</p>}><PayablePositionsWorkspace aging={page==='payable_aging'} key={context.token+':'+page} context={context} api={api}/></React.Suspense>}
|
||||
{page === 'receivable_positions' && <React.Suspense fallback={<p>A abrir posições…</p>}><ReceivablePositionsWorkspace context={context} api={api} onOpen={id=>navigate('service_receivables',{area:'service_receivables',id})}/></React.Suspense>}
|
||||
{page === 'service_receivables' && <ServiceReceivablesWorkspace context={context} can={can} api={api} busy={busy} initialObligationId={aSelection?.area==='service_receivables'?aSelection.id:null}/>}
|
||||
{page === 'service_invoice_setup' && <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==='billing_quotes'&&context.commercial_quotes_enabled===true&&<React.Suspense fallback={<p role="status">A abrir orçamentos…</p>}><CommercialQuotesWorkspace key={context.token} api={api} context={context} can={can} navigate={navigate} actorId={state.user.id}/></React.Suspense>}
|
||||
{page==='financial_indicators'&&<section className="panel"><h1>Critérios e indicadores financeiros QA</h1>{context.analysis_qa_enabled?<><p>Fontes conciliadas, critérios revistos e retratos explicáveis, sem rácios gerais ou recomendação de pagamento.</p><a href="/finance/indicators">Abrir critérios, indicadores e histórico</a></>:<p>Este percurso ainda não está disponibilizado neste contexto.</p>}</section>}
|
||||
{page==='bank_reconciliation'&&<section className="panel"><h1>Conferência de extracto QA</h1>{context.bank_qa_enabled?<><p>Extracto artificial, correspondências individuais com o diário existente e revisão distinta. Sem pagamentos ou banco real.</p><a href="/finance/bank">Abrir extracto, conferência e histórico</a></>:<p>O percurso ainda não está disponibilizado neste contexto.</p>}</section>}
|
||||
{page==='fiscal_rules'&&<section className="panel"><h1>Regras fiscais versionadas QA</h1>{context.fiscal_rules_qa_enabled?<><p>Fontes, parâmetros, revisão distinta e cálculo de ensaio por vigência. Sem activação legal ou atribuição automática a documentos.</p><a href="/fiscal/rules">Abrir catálogo, histórico e cálculo QA</a></>:<p>Este percurso ainda não está disponibilizado neste contexto.</p>}</section>}
|
||||
{page==='fiscal_iva'&&<section className="panel"><h1>Livro e mapa interno de IVA QA</h1>{context.fiscal_qa_enabled?<><p>Escolha explícita da origem, revisão distinta e mapa interno. Sem apuramento completo da empresa ou modelo oficial.</p><a href="/fiscal/iva">Abrir livro, mapa e histórico</a></>:<p>Este percurso ainda não está disponibilizado no contexto.</p>}</section>}
|
||||
{page === 'service_fiscal' && <React.Suspense fallback={<p>A abrir enquadramento fiscal…</p>}><ServiceFiscalWorkspace key={context.token} context={context} can={can} api={api} perform={perform} busy={busy} /></React.Suspense>}
|
||||
{page === 'catalogue_accounting' && <CatalogueAccountingWorkspace context={context} can={can} api={api} perform={perform} busy={busy} />}
|
||||
{page === 'access' && (can('access.manage') ? <AccessPanel context={context} can={can} api={api} perform={perform} busy={busy} /> : <section className="panel"><h1>Acesso não autorizado</h1><p>A gestão de acessos exige uma concessão própria.</p></section>)}
|
||||
</div>}
|
||||
</main><footer>SGF TEMPO · Rascunhos e confirmação interna · Sem emissão fiscal ou lançamentos</footer></div></div>;
|
||||
</main><footer>SGF TEMPO · {context.single_installation?'Apresentação local · Dados fictícios · Sem validade fiscal':'Âmbito de cada operação sujeito às autorizações e validações próprias'}</footer></div></div>;
|
||||
}
|
||||
function Login({ busy, onSubmit }) {
|
||||
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');
|
||||
@@ -166,3 +194,5 @@ function AccessPanel({ context, can, api, perform, busy }) {
|
||||
return <><section className="panel"><p className="eyebrow">AUTORIZAÇÕES EXPLÍCITAS</p><h1>Gestão de acessos</h1><p>Actua apenas nesta empresa e pode delegar apenas direitos que possui no mesmo âmbito. Os utilizadores são preparados pelo procedimento local.</p>{loadError && <p role="alert">{loadError}</p>}<form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email exacto do utilizador<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><div className="form-grid"><label>Âmbito<select value={scope} onChange={e => { setScope(e.target.value); setPermission(e.target.value === 'company' ? 'company.view' : 'unit.view'); }}><option value="company">Empresa activa</option>{context.units.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}</select></label><label>Permissão<select value={permission} onChange={e => setPermission(e.target.value)}>{options.map(p => <option key={p} value={p}>{labels[p]}</option>)}</select></label></div><label>Motivo da alteração<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><p className="help">Não escreva palavras-passe ou outros segredos no motivo. A revogação de consulta da empresa remove também os direitos dependentes, apenas se tiver autoridade sobre todos eles. Revogar a sua própria gestão pode exigir recuperação assistida de acessos.</p><div className="actions"><button className="primary" disabled={busy || !options.includes(permission)}>Conceder acesso</button><button type="button" disabled={busy || !email || !reason || !options.includes(permission)} onClick={() => change(false)}>Revogar acesso</button></div></form></section><section className="panel"><h2>Acessos nesta empresa</h2>{list ? <div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Permissão</th><th>Âmbito</th></tr></thead><tbody>{list.grants.map(g => { const member = list.members.find(m => m.id === g.user_id); return <tr key={g.id}><td>{member?.name}<small>{member?.email}</small></td><td>{labels[g.permission]}</td><td>{g.unit_id ? context.units.find(u => u.id === g.unit_id)?.name || 'Unidade autorizada' : 'Empresa'}</td></tr>; })}</tbody></table></div> : <p>A carregar acessos…</p>}</section></>;
|
||||
}
|
||||
createRoot(document.getElementById('app')).render(<App />);
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Saving uses the existing form and its existing command/validation path.
|
||||
export class ContextTransition {
|
||||
constructor() { this.dirty = new Set(); this.pending = 0; this.saved = 0; this.failed = 0; this.versions = new WeakMap(); this.submitting = null; }
|
||||
changed(form) { if (form) { this.dirty.add(form); this.versions.set(form,(this.versions.get(form)||0)+1); } }
|
||||
changed(form) { if (form && form.dataset?.sgfReadFilters!=='true') { this.dirty.add(form); this.versions.set(form,(this.versions.get(form)||0)+1); } }
|
||||
prune() { for (const form of this.dirty) if (!form.isConnected) this.dirty.delete(form); }
|
||||
async command(operation) {
|
||||
this.pending++;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const obligationLabels={active:'Obrigação administrativa revista',delivery_simulated:'Entrega apenas simulada — revista',cancelled:'Cancelamento administrativo',unapproved:'Sem condição aprovada'};
|
||||
export const decisionLabels={prepared:'Preparado — por rever',approved:'Revisto e aprovado',refused:'Recusado'};
|
||||
export function checkedObligations(data,query){if(data.policy!=='SGF-FISCAL-OBLIGATIONS-QA-v1'||data.synthetic_only!==true||data.official_submission!==false||data.tax_payable!==null||data.reference_on!==query.reference_on||!Array.isArray(data.records?.data)||data.records.current_page!==Number(query.page)||data.records.data.length>Number(query.per_page))throw Error('Resposta incompatível com a consulta administrativa pedida.');return data;}
|
||||
export function obligationsPageCsv(data,query){checkedObligations(data,query);const c=v=>'"'+String(v??'').replaceAll('"','""')+'"',t=v=>"'"+String(v??'');return [['SGF QA — OBRIGACOES ADMINISTRATIVAS — APENAS ESTA PAGINA'],['SEM PRAZO LEGAL OU SUBMISSAO A AT; ENTREGA APENAS SIMULADA'],['Referencia temporal',data.reference_on,'Filtros',JSON.stringify(query)],['Pagina',data.records.current_page,'de',data.records.last_page,'Registos do filtro',data.records.total],['Registo','Versao','Decisao','Periodo','Mapa interno','Prazo aprovado','Atraso dias','Estado administrativo','Responsavel QA','Evidencia aprovada','SHA evidencia simulada','Autor QA','Data','Fundamento'],...data.records.data.map(r=>[r.record_key,r.version,decisionLabels[r.state],r.data.period,r.data.form_id,r.effective?.due_on??'Desconhecido',r.days_overdue??'Indeterminado',obligationLabels[r.effective?.administrative_status??'unapproved'],r.effective?.responsible_id??'Sem aprovacao',t(r.effective?.evidence),r.effective?.delivery_evidence?.export_sha256??'',r.actor_id,r.created_at,t(r.reason)])].map(row=>row.map(c).join(';')).join('\r\n')+'\r\n';}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const statementCategories={asset:'Activo interno QA',liability:'Passivo interno QA',income:'Rendimento interno QA',expense:'Gasto interno QA',unclassified:'Por classificar'};
|
||||
export const statementStates={prepared:'Preparado — por rever',approved:'Revisto e aprovado',refused:'Recusado'};
|
||||
export function checkedStatements(data,query){if(data.policy!=='SGF-INTERNAL-STATEMENTS-QA-v1'||!Array.isArray(data.records?.data)||data.records.current_page!==Number(query.page)||data.records.data.length>Number(query.per_page)||data.view!==(query.view??'history'))throw Error('Resposta de demonstrações incompatível com a página pedida.');if(query.view==='result'&&(!data.snapshot||data.snapshot.summary.legal_model!==false||!data.snapshot.summary.synthetic_only))throw Error('Resultado sem âmbito QA explícito.');return data;}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function minorAmount(value){
|
||||
if((typeof value==='number'&&!Number.isSafeInteger(value))||!/^[-]?\d+$/.test(String(value)))throw Error('Montante sem representação inteira exacta.');
|
||||
const n=BigInt(value),a=n<0n?-n:n;return`${n<0n?'-':''}${a/100n},${String(a%100n).padStart(2,'0')}`;
|
||||
}
|
||||
export function ledgerReportQuery(form,page=1){
|
||||
if(!/^\d{4}-\d{2}-\d{2}$/.test(form.from_on)||!/^\d{4}-\d{2}-\d{2}$/.test(form.to_on)||form.from_on>form.to_on)throw Error('Escolha um intervalo de datas válido e explícito.');
|
||||
if(!['trial-balance','account-ledger'].includes(form.kind)||!['25','100'].includes(String(form.per_page)))throw Error('Consulta ou paginação inválida.');
|
||||
if(form.account_id&&(!/^[1-9]\d*$/.test(String(form.account_id))||!Number.isSafeInteger(Number(form.account_id))))throw Error('Indique um identificador de conta válido.');
|
||||
if(form.kind==='account-ledger'&&!form.account_id)throw Error('Escolha uma conta no balancete ou indique o seu identificador.');
|
||||
return{kind:form.kind,params:{from_on:form.from_on,to_on:form.to_on,per_page:String(form.per_page),page,...(form.account_id?{account_id:Number(form.account_id)}:{})}};
|
||||
}
|
||||
export function reportBasis(data){return JSON.stringify(data.basis);}
|
||||
export function checkedReport(data,query,expectedBasis=null){
|
||||
const kind=query.kind==='trial-balance'?'trial_balance':'account_ledger';
|
||||
if(data.kind!==kind||!data.synthetic_only||data.currency!=='MZN'||!Array.isArray(data.rows)||data.rows.length>Number(query.params.per_page)||data.query.from_on!==query.params.from_on||data.query.to_on!==query.params.to_on||data.pagination.page!==query.params.page||String(data.query.account_id??'')!==String(query.params.account_id??''))throw Error('A resposta não corresponde ao relatório e aos filtros pedidos.');
|
||||
if(data.basis?.kind!=='append_only_snapshot_marker'||!Number.isSafeInteger(data.basis.journals)||!Number.isSafeInteger(data.basis.last_journal_id)||data.basis.through_on!==query.params.to_on)throw Error('Referência do livro indisponível.');
|
||||
for(const [k,v]of Object.entries(data.totals??{}))if(k.endsWith('_minor'))minorAmount(v);
|
||||
if(expectedBasis!==null&&reportBasis(data)!==expectedBasis)throw Error('O livro mudou entre páginas. Actualize a primeira página e confira novamente.');
|
||||
for(const row of data.rows)for(const key of data.kind==='trial_balance'?['opening_net_minor','debit_minor','credit_minor','closing_net_minor']:['debit_minor','credit_minor','balance_minor'])minorAmount(row[key]);
|
||||
return data;
|
||||
}
|
||||
const csvCell=v=>'"'+String(v??'').replaceAll('"','""')+'"';
|
||||
export function reportPageCsv(data){
|
||||
const trial=data.kind==='trial_balance',header=trial?['Conta','Designacao','Moeda','Abertura liquida','Debito','Credito','Fecho liquido']:['Lancamento','Linha','Data','Fundamento','Debito','Credito','Saldo progressivo'];
|
||||
const rows=data.rows.map(r=>trial?["'"+r.code,"'"+r.label,r.currency,minorAmount(r.opening_net_minor),minorAmount(r.debit_minor),minorAmount(r.credit_minor),minorAmount(r.closing_net_minor)]:[r.journal_id,r.line_id,r.journal_on,"'"+r.reason,minorAmount(r.debit_minor),minorAmount(r.credit_minor),minorAmount(r.balance_minor)]);
|
||||
return [['RELATORIO INTERNO QA — SEM VALIDADE FISCAL'],['Apenas a pagina consultada',data.pagination.page,'de',data.pagination.last_page],['De',data.query.from_on,'ate',data.query.to_on],['Moeda',data.currency],['Referencia do livro',JSON.stringify(data.basis)],header,...rows].map(row=>row.map(csvCell).join(';')).join('\r\n')+'\r\n';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {minorAmount} from './ledger-report-view.js';
|
||||
export const documentStates={pending:'Factura pendente de conferência',validated:'Factura conferida'};
|
||||
export const payableStates={not_recognised:'Obrigação ainda não reconhecida',unpaid:'Por liquidar',partial:'Parcialmente liquidada',settled:'Liquidada no livro',indeterminate:'Saldo indeterminado'};
|
||||
export function payablePageSummary(data){
|
||||
const totals=new Map();let unrecognized=0,unknown=0;
|
||||
for(const r of data.positions){if(!r.obligation_id){unrecognized++;continue;}if(r.balance_minor===null){unknown++;continue;}minorAmount(r.balance_minor);totals.set(r.currency,(totals.get(r.currency)??0n)+BigInt(r.balance_minor));}
|
||||
return{totals:[...totals].map(([currency,value])=>({currency,value:minorAmount(String(value))})),unrecognized,unknown};
|
||||
}
|
||||
export const ageLabels={not_due:'Data revista ainda não ultrapassada',days_1_30:'1–30 dias',days_31_60:'31–60 dias',days_61_90:'61–90 dias',days_over_90:'Mais de90 dias',unknown:'Vencimento desconhecido',settled:'Saldo liquidado no livro',unrecognised:'Sem obrigação reconhecida',indeterminate:'Saldo indeterminado'};
|
||||
export function payableAgeSummary(data){const groups=new Map();for(const r of data.positions){if(!r.obligation_id||r.balance_minor===null||r.source_conflict||r.balance_minor==='0')continue;const bucket=r.reviewed_age_bucket;if(!bucket||!Object.hasOwn(ageLabels,bucket))throw Error('Faixa de atraso indisponível.');const key=r.currency+'|'+bucket;groups.set(key,(groups.get(key)??0n)+BigInt(r.balance_minor));}return[...groups].map(([key,total])=>{const[currency,bucket]=key.split('|');return{currency,bucket,value:minorAmount(String(total))};});}
|
||||
export function checkedPayablePage(data,query){
|
||||
if(data.balance_basis!=='original_current_payable_engine'||data.totals_basis!=='visible_page_only'||!data.synthetic_only||!Array.isArray(data.positions)||data.positions.length>Number(query.per_page)||data.pagination.page!==query.page)throw Error('A fonte não corresponde à página pedida.');
|
||||
if(query.reference_on&&(data.aging_reference_on!==query.reference_on||data.aging_basis!=='reviewed_administrative_terms_current_balance'))throw Error('Referência de atraso alterada; consulte novamente.');
|
||||
for(const r of data.positions){if(!Number.isSafeInteger(r.document_id)||r.document_id<1)throw Error('Documento sem identidade segura.');for(const field of ['principal_minor','balance_minor','confirmed_payments_minor','allocated_payments_minor'])if(r[field]!==null)minorAmount(r[field]);if((!r.obligation_id||r.source_conflict)&&r.balance_minor!==null)throw Error('Saldo sem reconhecimento/origem actual não pode ser presumido.');if(query.reference_on){if(!Object.hasOwn(ageLabels,r.reviewed_age_bucket))throw Error('Faixa administrativa ausente.');const known=r.reviewed_age_bucket==='not_due'||r.reviewed_age_bucket.startsWith('days_');if(known?(!Number.isSafeInteger(r.reviewed_age_days)||r.reviewed_age_days<0):r.reviewed_age_days!==null)throw Error('Dias administrativos incompatíveis.');}}
|
||||
return data;
|
||||
}
|
||||
export function payablePageCsv(data,query){const cell=v=>'"'+String(v??'').replaceAll('"','""')+'"',amount=v=>v===null?'Indeterminado':minorAmount(v);return [['POSICOES ACTUAIS DE FORNECEDORES QA'],['Apenas esta pagina',data.pagination.page,'de',data.pagination.last_page],['Leitura',data.observed_at],['Referencia para faixas administrativas',data.aging_reference_on??'Sem classificacao'],['Base','Saldo actual e prazo administrativo revisto; NAO E SALDO HISTORICO'],['Filtros',JSON.stringify(query)],['Documento','Fornecedor actual da origem','Moeda','Estado documental','Estado financeiro','Principal','Historico confirmado','Afectacoes liquidas','Saldo','Vencimento original','Condição administrativa revista','Evidência da condição revista','Faixa administrativa','Dias administrativos','Pendencias'],...data.positions.map(r=>["'"+r.reference,"'"+r.supplier,r.currency,documentStates[r.document_state]??r.document_state,payableStates[r.payment_state]??r.payment_state,amount(r.principal_minor),amount(r.confirmed_payments_minor),amount(r.allocated_payments_minor),amount(r.balance_minor),r.due_on??'Desconhecido',r.reviewed_terms?.due_on??'Desconhecido',"'"+(r.reviewed_terms?.evidence??''),ageLabels[r.reviewed_age_bucket]??'Sem classificacao',r.reviewed_age_days??'Indeterminado',"'"+(r.pending??[]).join(' | ')])].map(row=>row.map(cell).join(';')).join('\r\n')+'\r\n';}
|
||||
@@ -0,0 +1,5 @@
|
||||
// An explicit QA entry may name a page; it never selects a record or grants access.
|
||||
export function presentationLanding(context,groups,requested){
|
||||
if(!context?.single_installation||!requested)return null;
|
||||
return ['remunerations','service_invoice_setup','workshop','service_receivables'].includes(requested)&&groups.some(g=>g.items.some(i=>i.id===requested))?requested:false;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const positionStates={proposed:'Proposta por confirmar',refused:'Proposta recusada',open:'Em aberto',partial:'Parcialmente afectada',settled:'Liquidada por afectação/redução'};
|
||||
export const dueStates={not_open:'Ainda não reconhecida',unknown:'Vencimento desconhecido',overdue:'Saldo vencido',not_due:'Não vencido na referência',settled:'Sem saldo reconhecido por receber'};
|
||||
export function selectedReceivable(rows=[],id=''){return rows.find(r=>String(r.id)===String(id))??(!id?rows[0]??null:null);}
|
||||
export function exactMoney(value){if(value===null)return 'Indeterminado';if(typeof value!=='string'||!/^[-]?\d+\.\d{2}$/.test(value))throw Error('Montante sem representação decimal exacta.');return value.replace('.',',');}
|
||||
export function checkedPositions(data,query,previousBasis=null){
|
||||
if(!data.read_only||data.currency!=='MZN'||data.balance_basis!=='current_confirmed_effects_not_historical_cut'||data.reference_on!==query.reference_on||data.positions?.current_page!==query.page||data.positions.data.length>Number(query.per_page)||!data.basis?.scope)throw Error('A fonte não corresponde à página e à referência pedidas.');
|
||||
if(previousBasis!==null&&JSON.stringify(data.basis)!==previousBasis)throw Error('O livro de recebíveis mudou entre páginas. Actualize a primeira página.');
|
||||
exactMoney(data.totals.recognized_balance);for(const row of data.positions.data){for(const field of ['original','reduced','allocated','balance'])exactMoney(row[field]);if(!positionStates[row.state]||!dueStates[row.due_state])throw Error('Estado desconhecido na fonte.');if(!row.opened&&row.balance!==null)throw Error('Proposta sem reconhecimento não é saldo confirmado.');}
|
||||
return data;
|
||||
}
|
||||
export function positionsCsv(data,query){const cell=v=>'"'+String(v??'').replaceAll('"','""')+'"';const rows=[['POSICOES ACTUAIS QA — NAO E SALDO HISTORICO'],['Referencia apenas para vencimento',data.reference_on],['Pagina',data.positions.current_page,'de',data.positions.last_page,'Moeda','MZN'],['Filtros',JSON.stringify(query)],['Livro',JSON.stringify(data.basis)],['Documento','Cliente na emissao','Estado','Valor de origem','Reducao liquida','Afectacao liquida','Saldo reconhecido','Vencimento','Situacao','Dias de atraso','Revisao'],...data.positions.data.map(r=>["'"+r.reference,"'"+(r.client??'Por conferir'),positionStates[r.state],exactMoney(r.original),exactMoney(r.reduced),exactMoney(r.allocated),exactMoney(r.balance),r.due_date??'Desconhecido',dueStates[r.due_state],r.overdue_days??'Indeterminado',r.revision])];return rows.map(r=>r.map(cell).join(';')).join('\r\n')+'\r\n';}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Authenticated read with the existing context guard; no token in URL/history. */
|
||||
async function verifiedCsvDownload({id,token,sha256,isCurrent,pathForId,fetcher=fetch}){
|
||||
if(!Number.isSafeInteger(Number(id))||Number(id)<1||!token||!/^[a-f0-9]{64}$/.test(sha256))throw Error('Resultado ou SHA indisponível. Consulte novamente.');
|
||||
const check=()=>{if(!isCurrent())throw Error('O contexto mudou. A descarga anterior foi ignorada.');};check();
|
||||
const response=await fetcher(pathForId(Number(id)),{credentials:'same-origin',headers:{Accept:'text/csv','X-SGF-Context':token}});check();
|
||||
if(!response.ok){const problem=await response.json().catch(()=>({}));const error=Error(problem.message||'Não foi possível descarregar este resultado revisto.');error.status=response.status;throw error;}
|
||||
if(!/^text\/csv(?:;|$)/i.test(response.headers.get('content-type')??'')||response.headers.get('x-content-sha256')!==sha256)throw Error('A resposta não corresponde à exportação congelada.');
|
||||
const bytes=await response.arrayBuffer();check();const digest=await crypto.subtle.digest('SHA-256',bytes);check();const actual=[...new Uint8Array(digest)].map(n=>n.toString(16).padStart(2,'0')).join('');if(actual!==sha256)throw Error('O ficheiro não confere com o SHA conservado.');return{bytes,sha256:actual};
|
||||
}
|
||||
|
||||
export const statementDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/general-ledger/statements/exports/'+id});
|
||||
export const fiscalMapDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/fiscal-assessments/forms/'+id+'/export'});
|
||||
|
||||
export const remunerationDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/remunerations/exports/'+id});
|
||||
|
||||
export const personnelDownload=args=>verifiedCsvDownload({...args,pathForId:id=>'/api/hr-personnel/exports/'+id});
|
||||
@@ -0,0 +1,12 @@
|
||||
import {workshopStates} from './workshop-menu.js';
|
||||
export function checkedWorkshopReport(data,query){
|
||||
if(data.read_only!==true||data.states_are!=='current_resource_records_not_delivery_or_payment'||!Array.isArray(data.orders?.data)||data.orders.current_page!==query.page||data.orders.data.length>Number(query.per_page))throw Error('A resposta não corresponde à página de ordens pedida.');
|
||||
if(query.equipment_id&&data.equipment?.id!==Number(query.equipment_id))throw Error('A resposta pertence a outro equipamento. Consulte novamente.');
|
||||
if(query.from&&(data.opening_period?.from!==query.from||data.opening_period?.to!==query.to||data.opening_period?.basis!=='order_created_at_current_records'||data.opening_period?.zone!=='Africa/Johannesburg'))throw Error('O período da resposta mudou. Consulte novamente.');
|
||||
if(!query.from&&data.opening_period!==null)throw Error('Período inesperado na consulta.');
|
||||
return data;
|
||||
}
|
||||
export function workshopPageCsv(data,query){
|
||||
checkedWorkshopReport(data,query);const cell=v=>'"'+String(v??'').replaceAll('"','""')+'"',text=v=>"'"+String(v??''),p=data.opening_period;
|
||||
return [['SGF QA — ORDENS DE OFICINA — APENAS ESTA PAGINA'],['Periodo de abertura',p?.from??'Sem corte',p?.to??'Sem corte','Zona',p?.zone??'UTC da origem'],['Base','Apontamentos actuais; NAO E FECHO DIARIO, ENTREGA OU SALDO HISTORICO'],['Equipamento escolhido',text(data.equipment?.identifier??'Sem filtro individual'),text(data.equipment?.description),text(data.equipment?.client_name)],['Leitura',data.observed_at],['Pagina',data.orders.current_page,'de',data.orders.last_page,'Total de ordens do filtro',data.orders.total],['Filtros',JSON.stringify(query)],['Ordem','Cliente actual','Equipamento actual','Descricao actual','Serie','Responsavel actual','Abertura na origem','Estado dos apontamentos','Apontamentos actuais','Sem verificacao favoravel','Minutos apontados','Minutos verificados'],...data.orders.data.map(r=>[text(r.reference),text(r.client),text(r.equipment),text(r.description),text(r.serial),text(r.technician),text(r.created_at),workshopStates[r.state]??text(r.state),r.points,r.points_not_verified,r.minutes_reported,r.minutes_verified])].map(row=>row.map(cell).join(';')).join('\r\n')+'\r\n';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export class WorkshopStockOperation {
|
||||
constructor(api){this.api=api;this.pending=false;this.uncertain=false;this.operation=null;}
|
||||
async send(path,body,key){
|
||||
if(this.pending||this.uncertain)throw new Error('Recupere primeiro a operação pendente.');
|
||||
this.operation={path,body:JSON.parse(JSON.stringify(body)),key};return this.execute();
|
||||
}
|
||||
async retry(){if(!this.uncertain||this.pending)throw new Error('Não existe uma operação incerta para recuperar.');return this.execute();}
|
||||
async execute(){
|
||||
this.pending=true;const op=this.operation;
|
||||
try{const result=await this.api(op.path,'POST',op.body,undefined,op.key);
|
||||
if(!result||!Number.isInteger(result.id??result.movement_id??result.claim_id))throw new Error('Resposta incompleta; conserve a decisão para recuperação.');
|
||||
this.uncertain=false;return result;
|
||||
}catch(e){this.uncertain=!Number.isInteger(e.status)||e.status>=500||e.status===429;throw e;}
|
||||
finally{this.pending=false;}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Read existing, authorized pages. No cross-module queries, writes or invented aggregates.
|
||||
export const moduleGuides={
|
||||
human_resources:{label:'Recursos Humanos',purpose:'Acompanhar cadastro fictício e remunerações internas com identidade e direitos próprios.',steps:['Cadastro revisto','Regras artificiais revistas','Processamento individual','Revisão distinta','Mapa conservado'],evidence:'Cada versão conserva fonte, autor, fundamento e resultado explicável; a exportação confere por SHA.',boundary:'Processamento interno QA não é salário pago nem cálculo legal IRPS/INSS. Contratos, assiduidade e férias continuam pendentes.',next:'A ligação salarial a Financeiro e Contabilidade ainda não cria dívida, pagamentos ou lançamentos.'},
|
||||
commercial:{label:'Comercial',purpose:'Acompanhar clientes, propostas, documentos e a passagem para recebimentos.',steps:['Cliente e serviço','Orçamento e aceitação','Rascunho e revisão','Documento de teste','Recebível e afectação'],evidence:'Número, cliente, versão, emissão e PDF identificam o documento. O recibo conserva a afectação de origem.',boundary:'Orçamento e rascunho não são receita. O saldo de uma factura pode aparecer em vários documentos ligados; não deve ser somado.',next:'A cobrança é acompanhada no Financeiro, conservando a factura de origem.'},
|
||||
purchasing:{label:'Compras',purpose:'Acompanhar a aquisição desde a necessidade até à recepção e obrigação reconhecida.',steps:['Requisição','Propostas e avaliação','Adjudicação e ordem','Recepção e factura','Obrigação reconhecida'],evidence:'O dossiê mantém decisões, propostas, documentos recebidos, factura conferida e a origem da obrigação.',boundary:'Receber material não comprova pagamento. Uma factura pendente não reserva nem confirma automaticamente uma obrigação.',next:'A recepção alimenta o mesmo Stock; a obrigação segue para conferência financeira individual.'},
|
||||
stock:{label:'Stock',purpose:'Conhecer o físico por artigo e armazém e rastrear entradas, saídas, trânsito e contagens.',steps:['Artigo e armazém','Recepção ou saída','Transferência e recepção','Lotes e contagem','Conferência da valorização'],evidence:'Cada movimento conserva quantidade, unidade, armazém e origem. Lotes e trânsito têm decisões próprias.',boundary:'Quantidades de unidades diferentes não são somadas. Físico, reservado, bloqueado e disponível não são equivalentes.',next:'Consumos e devoluções da Oficina mantêm a entrega original; valorização é conferida na Contabilidade.'},
|
||||
workshop:{label:'Oficinas',purpose:'Organizar equipamento, cliente, agenda, autorização e trabalho efectivamente verificado.',steps:['Equipamento e cliente','Ordem e agenda','Autorização do trabalho','Materiais e horas','Conferência e facturação'],evidence:'A ordem liga equipamento, autorização do cliente, tarefa, técnico e apontamentos verificados.',boundary:'Agendar não autoriza trabalho. Horas apontadas não são horas verificadas; material declarado não substitui o movimento físico.',next:'O custo validado e o rascunho comercial conservam a ordem; não há emissão automática.'},
|
||||
finance:{label:'Financeiro',purpose:'Conferir recebíveis, obrigações, Caixa, extractos e indicadores com as suas fontes.',steps:['Documento de origem','Preparação individual','Revisão distinta','Afectação ou conferência','Histórico e relatório'],evidence:'Recibos, afectações, contagens e correspondências bancárias permitem conferir quem decidiu e com que fundamento.',boundary:'Caixa, saldo de cliente e saldo de fornecedor são factos diferentes. Data de vencimento desconhecida não significa atraso.',next:'Os efeitos autorizados são conferidos no diário único; uma preparação não comprova execução bancária.'},
|
||||
accounting:{label:'Contabilidade',purpose:'Consultar origens, diário, balancete, razão e os cálculos fiscais internos disponíveis.',steps:['Origem validada','Proposta contabilística','Revisão independente','Aplicação no diário','Relatório e período'],evidence:'Cada lançamento mantém origem e revisão; correcções conservam o original e o par de estorno/substituição.',boundary:'Só lançamentos aplicados entram nos montantes. Mapas e regras fiscais QA não são declarações ou modelos oficiais.',next:'A conferência do período reúne evidências e pendências; não reabre nem fecha um período por consultar.'},
|
||||
};
|
||||
export function overviewSources(context,can){
|
||||
const unit=context?.unit?.id;if(!unit)return [];
|
||||
const add=(id,label,permission,enabled,path,pageKey,target,href)=>({id,label,permission,enabled,path,pageKey,target,href});
|
||||
const definitions={
|
||||
commercial:[add('documents','Documentos comerciais','service_documents.view',context.service_documents_enabled,'/api/service-documents/register?per_page=25','documents','billing_all')],
|
||||
purchasing:[add('requests','Requisições','purchasing_requests.view',context.single_installation,'/api/purchasing-requests','requests',null,'/purchasing/requests')],
|
||||
stock:[add('balances','Existências físicas','stock.view',context.stock?.read===true||context.stock?.enabled===true||context.single_installation,'/api/stock-read?view=balances&per_page=25',null,'stock_balances')],
|
||||
workshop:[add('orders','Ordens e apontamentos','workshop.view',context.single_installation,'/api/workshop/orders?per_page=25','orders','workshop_panel'),add('equipment','Equipamentos e clientes','workshop.view',context.workshop_catalogue_enabled,'/api/workshop-equipment?per_page=25','records','workshop_equipment')],
|
||||
finance:[add('cash','Histórico de Caixa','cash.view',context.cash_enabled,'/api/cash?per_page=25','events',null,'/finance/cash'),add('indicators','Indicadores conservados','financial_analysis.view',context.analysis_qa_enabled,'/api/financial-indicators?view=results&per_page=25','records','financial_indicators')],
|
||||
accounting:[add('journals','Lançamentos aplicados','general_ledger.view',context.accounting?.single_book,'/api/general-ledger',null,null,'/general-ledger'),add('calculations','Cálculos fiscais internos','fiscal_rules.view',context.fiscal_rules_qa_enabled,'/api/fiscal-rules?view=calculations&per_page=25','records','fiscal_rules')],
|
||||
};
|
||||
return(definitions[context.module_id]??[]).filter(s=>s.enabled===true&&can(s.permission,unit));
|
||||
}
|
||||
const moneyMinor=(n,currency='MZN')=>{if(n===null||n===undefined)return 'Indeterminado';if(typeof n==='number'&&!Number.isSafeInteger(n))throw Error('Montante sem representação inteira segura.');const x=BigInt(n),a=x<0n?-x:x;return`${x<0n?'-':''}${a/100n},${String(a%100n).padStart(2,'0')} ${currency}`;};
|
||||
const quantity=n=>{const x=BigInt(n),a=x<0n?-x:x;return`${x<0n?'-':''}${a/1000n},${String(a%1000n).padStart(3,'0')}`;};
|
||||
const states={draft:'Rascunho',pending:'Pendente',approved:'Aprovado',rejected:'Recusado',issued:'Emitido em teste',reviewed:'Revisto',prepared:'Preparado',closed:'Fechado',determined_qa:'Calculado QA',pending_representation:'Representação pendente'};
|
||||
export function overviewPage(source,data){
|
||||
const p=source.pageKey?data[source.pageKey]:data.pagination;
|
||||
if(!p||!Number.isSafeInteger(p.total)||!Number.isSafeInteger(p.current_page??p.page)||!Number.isSafeInteger(p.last_page))throw Error('A fonte não devolveu uma página conferida. Abra a consulta completa.');
|
||||
const input=source.pageKey?p.data:source.id==='journals'?data.journals:data.rows;
|
||||
if(!Array.isArray(input)||input.length>25)throw Error('A página recebida não respeita o limite da consulta.');
|
||||
const rows=input.map(r=>{
|
||||
const state=states[r.document_state??r.status??r.state??r.result?.state]??(r.document_state??r.status??r.state??r.result?.state??'Consultar origem');
|
||||
if(source.id==='documents')return{reference:r.reference,detail:r.client??'Cliente por determinar',state,value:r.amount===null?'Montante por determinar':`${r.amount} ${r.currency}`,evidence:`${r.kind} · versão ${r.version} · ${r.document_on??'Não emitido'}`};
|
||||
if(source.id==='requests')return{reference:r.number,detail:'Requisição de aquisição',state,value:'Montante na requisição',evidence:`Versão ${r.process_version}`};
|
||||
if(source.id==='balances')return{reference:r.code,detail:r.name,state:'Saldo físico observado',value:quantity(r.physical_milli)+' '+r.unit,evidence:`${r.warehouse_code} · ${r.warehouse_name} · disponível indeterminado`};
|
||||
if(source.id==='orders')return{reference:r.reference,detail:`${r.client??'Cliente por conferir'} · ${r.equipment??'Equipamento por conferir'}`,state:({open:'Aberta',in_review:'Apontamentos em revisão',exception_review:'Ocorrências fora do âmbito',records_verified:'Registos verificados'})[r.state]??r.state,value:`${r.minutes_verified} min verificados / ${r.minutes_reported} min apontados`,evidence:`${r.technician??'Por atribuir'} · ${r.points_not_verified} registo(s) sem verificação favorável`};
|
||||
if(source.id==='equipment')return{reference:r.identifier,detail:r.description,state:r.client_name,value:r.serial??'Série não registada',evidence:`Equipamento #${r.id} · cliente associado explicitamente`};
|
||||
if(source.id==='journals')return{reference:'Lançamento #'+r.id,detail:r.reason,state:'Aplicado',value:`Débito ${moneyMinor(r.debit_minor,r.currency)} / crédito ${moneyMinor(r.credit_minor,r.currency)}`,evidence:`${r.journal_on} · ${r.currency} · revisão #${r.review_id}`};
|
||||
if(source.id==='cash')return{reference:'Caixa · evento #'+r.id,detail:r.reason,state,value:r.data?.amount_minor===undefined?'Sem montante próprio':moneyMinor(r.data.amount_minor),evidence:`${r.kind} · ${r.action} · versão ${r.version} · ${r.data?.date??'Data no histórico'}`};
|
||||
if(source.id==='indicators')return{reference:'Indicador #'+r.id,detail:r.metric,state,value:r.result?.value===null?'Indeterminado':`${r.result?.value??'Indeterminado'} ${r.result?.unit==='percent'?'%':r.result?.unit==='times'?'vezes':''}`,evidence:`Exacto ${r.result?.exact_fraction??'indeterminado'} · ${r.result?.as_of??'Corte no relatório'}`};
|
||||
return{reference:'Cálculo #'+r.id,detail:r.reason,state,value:r.result?.total===null?'Total indeterminado':`${r.result?.total??'Indeterminado'} ${r.input?.currency??''}`,evidence:`${r.input?.date??'Data na origem'} · ${r.result?.price_mode??'Base simples'} · regra #${r.rule_event_id??'não escolhida'}`};
|
||||
});
|
||||
const facts=source.id==='cash'?[{label:'Saldo actual da Caixa QA',value:data.basis?moneyMinor(data.basis.balance_minor):'Por conferir'},{label:'Estado da sessão',value:data.basis?(data.basis.closed?'Fechada':data.basis.open?'Aberta':'Abertura por conferir'):'Fonte não conferida'}]:[];
|
||||
return{rows,total:p.total,page:p.current_page??p.page,last:p.last_page,observed:data.observed_at??null,facts};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Diário integrado · SGF</title><style>body{font:16px system-ui;background:#f3f7f5;color:#173e36;margin:0}main{max-width:1120px;margin:auto;padding:24px}section,article{background:white;border:1px solid #bed0c9;border-radius:8px;padding:18px;margin:16px 0}label{display:block;margin:10px 0}input,textarea,select,button{font:inherit;padding:10px;max-width:100%;box-sizing:border-box}input,select,textarea{display:block;width:100%}table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:8px;border-bottom:1px solid #ddd}a{color:#174d71}.notice{background:#fff3cf;padding:14px}nav{display:flex;gap:18px;flex-wrap:wrap}</style></head><body><main id="composed-ledger" data-context="{{ $context }}" data-csrf="{{ $csrf }}">
|
||||
@php($money=fn($v)=>number_format($v/100,2,',',' ').' MZN')
|
||||
<section><h2>Procurar a origem individual</h2><form method="get"><label>Tipo de origem<select name="source_kind">@foreach(['all'=>'Todas as famílias','supplier_payable.recognised'=>'Obrigação de compra','allocation.confirmed'=>'Afectação a fornecedor','allocation.reversed'=>'Reversão a fornecedor','sales.invoice'=>'Documento comercial','receivable.allocated'=>'Afectação de cliente','receivable.reversed'=>'Reversão de cliente','receivable.reduced'=>'Crédito nominal de cliente','receivable.reduction_reversed'=>'Reversão do crédito nominal','workshop.direct_cost.validated'=>'Custo de oficina','industry.cost.validated'=>'Custo industrial','cash.receipt_classified'=>'Classificação de Caixa','cash.expense_confirmed'=>'Despesa de Caixa','stock.receipt'=>'Entrada física','stock.issue'=>'Saída física','stock.return'=>'Devolução física','stock.adjustment'=>'Diferença de inventário','stock.supplier_return'=>'Devolução ao fornecedor'] as $value=>$label)<option value="{{ $value }}" @selected($data['source_pagination']['kind']===$value)>{{ $label }}</option>@endforeach</select></label><label>Registos de cada família por página<select name="source_per_page">@foreach([25,100] as $size)<option value="{{ $size }}" @selected($data['source_pagination']['per_page']===$size)>{{ $size }}</option>@endforeach</select></label><button>Consultar origens</button></form><p>As famílias são paginadas separadamente. Escolher um tipo não escolhe um documento nem prepara um lançamento.</p>@php($p=$data['source_pagination'])<nav aria-label="Paginação das origens">Página {{ $p['page'] }} de {{ $p['last_page'] }} @if($p['page']>1)<a href="{{ request()->fullUrlWithQuery(['source_page'=>$p['page']-1]) }}">Anterior</a>@endif @if($p['page']<$p['last_page'])<a href="{{ request()->fullUrlWithQuery(['source_page'=>$p['page']+1]) }}">Seguinte</a>@endif</nav></section>
|
||||
<nav><a href="/">Voltar ao SGF</a><a href="/sgf">Trocar função QA</a><a href="/general-ledger/trial-balance?from_on=2026-01-01&to_on=2026-12-31">Balancete e razão por conta</a></nav><h1>Contabilidade · diário integrado</h1><p>Empresa {{ $data['sgf_scope']['company_id'] }} · Unidade {{ $data['sgf_scope']['unit_id'] }}</p><p class="notice">Contas e período artificiais. Um só diário para origens de Compras, Comercial, Oficinas e Stock. Preparar não lança; revisão distinta e aplicação individual voltam a conferir a origem. Estes dados não constituem emissão fiscal, pagamento real ou demonstração financeira legal.</p><p id="ledger-result" role="status"></p>
|
||||
@if($data['permissions']['prepare'])<section><h2>Preparar um lançamento</h2><form data-ledger-action="capture"><label>Origem individual<select name="source" required><option value="">Escolha explicitamente o documento ou efeito</option>@foreach($data['sources'] as $source)<option value="{{ $source['kind'] }}|{{ $source['id'] }}">{{ $source['label'] }}@if($source['amount']!==null) · {{ $money($source['amount']) }}@endif</option>@endforeach</select></label><label>Data explícita<input name="journal_on" type="date" required></label><label>Período<select name="period_key" required><option value="">Escolha</option>@foreach($data['cycle']['periods'] as $period)@if($period->status==='open')<option value="{{ $period->period_key }}">{{ $period->period_key }} · {{ $period->starts_on }} a {{ $period->ends_on }}</option>@endif @endforeach</select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Preparar a fotografia e mostrar o impacto</button></form></section>@endif
|
||||
<section><h2>Conferências e impacto</h2>@foreach($data['proposals'] as $p)@php($snapshot=json_decode($p->snapshot,true))@php($review=collect($data['reviews'])->first(fn($r)=>$r->proposal_id===$p->id))@php($part=collect($data['cycle']['corrections'])->contains(fn($c)=>in_array($p->id,[$c->reverse_proposal_id,$c->replacement_proposal_id],true)))<article><h3>Conferência #{{ $p->id }} · versão {{ $p->capture_version }}</h3><p>{{ $p->reason }}</p><p>Data {{ $snapshot['journal_on'] }} · {{ $snapshot['period']['period_key'] }} · Preparador #{{ $p->prepared_by }}</p><table><thead><tr><th>Conta artificial</th><th>Débito</th><th>Crédito</th></tr></thead><tbody>@foreach($snapshot['lines'] as $line)<tr><td>{{ $line['account_code'] }}</td><td>{{ $money($line['debit_minor']) }}</td><td>{{ $money($line['credit_minor']) }}</td></tr>@endforeach</tbody></table><p>Impacto equilibrado: {{ $money($snapshot['impact']['debit_minor']) }}. Sem alterar o saldo financeiro ou executar outro movimento físico.</p>
|
||||
@if($review)<p>Decisão: {{ $review->decision==='approved'?'Aprovada':'Recusada' }} por outra função · {{ $review->reason }}</p>@endif
|
||||
@if($p->journal_id)<p>Aplicada no lançamento #{{ $p->journal_id }}.</p>@elseif(!$part&&$data['permissions']['validate']&&!$review&&$p->prepared_by!==$data['current_actor'])<form data-ledger-action="validate"><input type="hidden" name="proposal_id" value="{{ $p->id }}"><input type="hidden" name="revision" value="{{ $p->revision }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar este impacto</option><option value="rejected">Recusar</option></select></label><label>Fundamento independente<textarea name="reason" required minlength="3"></textarea></label><button>Revalidar e registar revisão</button></form>
|
||||
@elseif(!$part&&$data['permissions']['post']&&$review?->decision==='approved'&&$p->prepared_by===$data['current_actor'])<form data-ledger-action="post"><input type="hidden" name="proposal_id" value="{{ $p->id }}"><input type="hidden" name="revision" value="{{ $p->revision }}"><label>Fundamento da aplicação<textarea name="reason" required minlength="3"></textarea></label><button>Revalidar e aplicar este impacto uma vez</button></form>@endif</article>@endforeach</section>
|
||||
<section><h2>Lançamentos</h2><p>Débitos {{ $money($data['totals']['debit_minor']) }} · Créditos {{ $money($data['totals']['credit_minor']) }}</p><table><thead><tr><th>Lançamento</th><th>Data</th><th>Valor</th><th>Fundamento</th></tr></thead><tbody>@foreach($data['journals'] as $j)<tr><td>#{{ $j->id }}</td><td>{{ $j->journal_on }}</td><td>{{ $money($j->debit_minor) }}</td><td>{{ $j->reason }}</td></tr>@endforeach</tbody></table><nav>Página {{ $data['pagination']['page'] }} de {{ $data['pagination']['last_page'] }} @if($data['pagination']['page']>1)<a href="?page={{ $data['pagination']['page']-1 }}">Anterior</a>@endif @if($data['pagination']['page']<$data['pagination']['last_page'])<a href="?page={{ $data['pagination']['page']+1 }}">Seguinte</a>@endif</nav></section>
|
||||
<section><h2>Correcção por estorno e substituição</h2>@if($data['permissions']['correct_prepare'])<form data-ledger-action="correct-prepare"><label>Original a corrigir<select name="journal_id" required><option value="">Escolha o original</option>@foreach($data['journals'] as $j)@php($kind=json_decode($j->snapshot,true)['source']['effect_type'])@if(in_array($kind,['supplier_payable.recognised','workshop.direct_cost.validated','industry.cost.validated'],true))<option value="{{ $j->id }}">#{{ $j->id }} · {{ $money($j->debit_minor) }} · {{ $j->journal_on }}</option>@endif @endforeach</select></label><label>Data da correcção<input name="journal_on" type="date" required></label><label>Período<input name="period_key" value="QA-2026-INTEGRADO" required></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Preparar o par indivisível para revisão</button></form>@endif
|
||||
@foreach($data['cycle']['corrections'] as $c)@php($reverse=json_decode($c->reverse_snapshot,true))@php($replace=json_decode($c->replacement_snapshot,true))<article><h3>Correcção #{{ $c->id }} do original #{{ $c->original_journal_id }}</h3><p>Estorno {{ $money($reverse['impact']['debit_minor']) }} e substituição {{ $money($replace['impact']['debit_minor']) }} · {{ $c->reason }}</p>@if($c->result)<p>Par aplicado nos lançamentos #{{ $c->result->reverse_journal_id }}/#{{ $c->result->replacement_journal_id }}.</p>@elseif($data['permissions']['correct_validate']&&!$c->reverse_review&&$c->prepared_by!==$data['current_actor'])<form data-ledger-action="correct-validate"><input type="hidden" name="correction_id" value="{{ $c->id }}"><input type="hidden" name="revision" value="{{ $c->revision }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar o par mostrado</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Rever o par exacto</button></form>@elseif($data['permissions']['correct_post']&&$c->reverse_review?->decision==='approved'&&$c->replacement_review?->decision==='approved'&&$c->prepared_by===$data['current_actor'])<form data-ledger-action="correct-post"><input type="hidden" name="correction_id" value="{{ $c->id }}"><input type="hidden" name="revision" value="{{ $c->revision }}"><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Aplicar estorno e substituição atomicamente</button></form>@endif</article>@endforeach</section>
|
||||
<section><h2>Conferência e fecho do período de ensaio</h2>@if($data['permissions']['period_prepare'])<form data-ledger-action="period-prepare"><label>Período<input name="period_key" value="QA-2026-INTEGRADO" required></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Conferir origens e pendências antes do fecho</button></form>@endif
|
||||
@foreach($data['cycle']['period_checks'] as $check)@php($snapshot=json_decode($check->snapshot,true))@php($review=collect($data['cycle']['period_reviews'])->first(fn($r)=>$r->check_id===$check->id))@php($closed=collect($data['cycle']['period_closures'])->contains(fn($r)=>$r->check_id===$check->id))<article><h3>{{ $check->period_key }} · conferência #{{ $check->id }}</h3><p>{{ $snapshot['facts'] }} factos · {{ $snapshot['pending'] }} pendências · Débitos {{ $money($snapshot['debit_minor']) }} / Créditos {{ $money($snapshot['credit_minor']) }}</p>@if($closed)<p>Período QA fechado; sem reabertura neste percurso.</p>@elseif($data['permissions']['period_validate']&&!$review&&$check->prepared_by!==$data['current_actor'])<form data-ledger-action="period-validate"><input type="hidden" name="check_id" value="{{ $check->id }}"><input type="hidden" name="revision" value="{{ $check->revision }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar conferência</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Rever a conferência exacta</button></form>@elseif($data['permissions']['period_close']&&$review?->decision==='approved'&&$check->prepared_by===$data['current_actor'])<form data-ledger-action="period-close"><input type="hidden" name="check_id" value="{{ $check->id }}"><input type="hidden" name="revision" value="{{ $check->revision }}"><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Revalidar tudo e fechar o período QA</button></form>@endif</article>@endforeach</section>
|
||||
<section><h2>Stock · taxa de valorização</h2>@foreach($data['stock'] as $s)<article><p>Artigo #{{ $s['book']['stock_item_id'] }} · Armazém #{{ $s['book']['warehouse_id'] }} · Saldo físico {{ $s['physical_balance_milli']/1000 }} {{ $s['book']['unit_code'] }}</p>@if(isset($s['ownership']['owned_quantity_milli']))<p>Propriedade total deste artigo {{ $s['ownership']['owned_quantity_milli']/1000 }} {{ $s['book']['unit_code'] }}, incluindo {{ $s['ownership']['in_transit_milli']/1000 }} em trânsito. Transferências internas não são consumo.</p>@else<p>{{ $s['ownership']['ownership_pending']??'Propriedade por conferir' }}</p>@endif @if($data['permissions']['prepare'])<form data-ledger-action="rate-prepare" data-stock-rate><input type="hidden" name="book_id" value="{{ $s['book']['id'] }}"><input type="hidden" name="version" value="{{ $s['rates'][0]->version??0 }}"><label>Custo unitário artificial (MZN/UN)<input name="unit_amount" required inputmode="decimal"></label><label>Fundamento<textarea name="reason" required minlength="10"></textarea></label><button>Preparar taxa para revisão distinta</button></form>@endif @foreach($s['rates'] as $rate)<p>Taxa v{{ $rate->version }} · {{ $money($rate->unit_minor) }}/UN · {{ $rate->review?->decision??'Por rever' }}</p>@if($data['permissions']['validate']&&!$rate->review&&$rate->prepared_by!==$data['current_actor'])<form data-ledger-action="rate-review" data-stock-rate><input type="hidden" name="rate_id" value="{{ $rate->id }}"><input type="hidden" name="version" value="{{ $rate->version }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="approved">Aprovar esta taxa</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minlength="10"></textarea></label><button>Rever a taxa actual</button></form>@endif @endforeach</article>@endforeach</section>
|
||||
<section id="chart"><h2>Plano de contas de teste</h2><p>Contas artificiais do mesmo diário e desta empresa/unidade. A classificação comercial tem proposta e revisão próprias; consultar uma conta não cria um lançamento.</p><form method="get"><label>Pesquisar código ou designação<input name="account_q" maxlength="80" value="{{ request('account_q','') }}"></label><label>Contas por página<select name="accounts_per_page">@foreach([25,100] as $size)<option value="{{ $size }}" @selected($data['chart_pagination']['per_page']===$size)>{{ $size }}</option>@endforeach</select></label><button>Consultar contas</button></form><table><thead><tr><th>Código</th><th>Designação</th><th>Moeda</th><th>Consulta</th></tr></thead><tbody>@foreach($data['chart_accounts'] as $account)<tr><td>{{ $account->code }}</td><td>{{ $account->label }}</td><td>{{ $account->currency }}</td><td><a href="{{ '/general-ledger/account-ledger?'.http_build_query(['from_on'=>'2026-01-01','to_on'=>'2026-12-31','account_id'=>$account->id]) }}">Abrir razão desta conta</a></td></tr>@endforeach</tbody></table>@php($p=$data['chart_pagination'])<nav aria-label="Paginação do plano de contas">Página {{ $p['page'] }} de {{ $p['last_page'] }} @if($p['page']>1)<a href="{{ request()->fullUrlWithQuery(['accounts_page'=>$p['page']-1]) }}#chart">Anterior</a>@endif @if($p['page']<$p['last_page'])<a href="{{ request()->fullUrlWithQuery(['accounts_page'=>$p['page']+1]) }}#chart">Seguinte</a>@endif</nav></section>
|
||||
</main><script type="module" src="/sgf-composed-ledger.mjs"></script></body></html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><meta name="document-kind" content="{{ $kind }}"><title>Comercial · documentos de teste</title><style>body{font:16px system-ui;max-width:1100px;margin:auto;padding:24px;color:#142b3b;background:#f4f7fa}section{background:white;padding:20px;margin:18px 0;border:1px solid #ccd9e1;border-radius:10px}label{display:block;margin:10px 0}input,select,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}button{margin:5px;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{padding:9px;text-align:left;border-bottom:1px solid #ddd}fieldset{border:0;padding:0}[role=alert]{color:#8b1e22;white-space:pre-wrap}dt{font-weight:bold}dd{margin-bottom:10px}</style></head><body>
|
||||
<a href="/">← Voltar ao SGF</a> · <a href="/sgf">Escolher função</a><h1>Comercial · documentos de teste</h1><p>TESTE — SEM VALIDADE FISCAL. Sem dinheiro real. Preparação, revisão e emissão são decisões individuais; a entrada não executa operações.</p><p id="message" role="alert"></p>
|
||||
<section><fieldset id="filters"><label>Documento<select id="kind"><option value="receipt">Recibo</option><option value="credit_note">Nota de crédito</option><option value="invoice_receipt">Factura-recibo</option></select></label><label>Registos por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><button id="refresh">Actualizar</button><button id="new">Novo processo</button><button id="all">Voltar aos processos actuais</button><table><thead><tr><th>Processo</th><th>Versão / estado</th><th>Autor e data</th><th>Consultar</th></tr></thead><tbody id="records"></tbody></table><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><p id="selected"></p><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<input id="reason" required minlength="10" maxlength="4000"></label><button>Conferir impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Descartar prévia</button><p id="receipt" role="status"></p></section><script type="module" src="/sgf-service-documents.mjs"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Orçamento QA · v{{ $quote->version }}</title><style>body{font:16px system-ui;max-width:850px;margin:40px auto;padding:16px;color:#183c35}table{width:100%;border-collapse:collapse;margin:25px 0}td,th{text-align:left;padding:10px;border-bottom:1px solid #ddd}.notice{padding:15px;border:2px solid #526f64}small{overflow-wrap:anywhere}@media print{button,a{display:none}body{margin:0}}</style></head><body><a href="/">Voltar ao SGF</a><h1>Orçamento de teste</h1><p class="notice"><strong>SEM VALIDADE FISCAL</strong> · {{ $quote->state==='draft'?'Rascunho — aguarda revisão':$quote->state }}. Não é factura, recibo, autorização de trabalho ou dívida.</p><p>Cliente: <strong>{{ $quote->dependencies['client']['name'] }}</strong></p><p>{{ $quote->data['description'] }}</p><p>Validade: {{ $quote->data['valid_until'] }} · Versão {{ $quote->version }}</p>@if($quote->dependencies['origin'])<p>Ordem de origem: {{ $quote->dependencies['origin']['order']['reference'] }}</p>@endif<table><thead><tr><th>Serviço/descrição</th><th>Quantidade</th><th>Preço unitário proposto</th></tr></thead><tbody>@foreach($quote->data['lines'] as $line)<tr><td>{{ $line['service_name'] }}<br>{{ $line['description']??'' }}</td><td>{{ $line['quantity'] }}</td><td>{{ $line['unit_price'] }} MZN</td></tr>@endforeach</tbody></table><h2>Base proposta: {{ $quote->data['base'] }} MZN</h2><p>Impostos sujeitos a conferência separada. Não se presumiu imposto zero nem este valor como total fiscal.</p>@if(isset($quote->data['customer']))<section><h2>Decisão documentada</h2><p>{{ $quote->data['customer']['decision'] }} · {{ $quote->data['customer']['representative'] }} · {{ $quote->data['customer']['capacity'] }} · {{ $quote->data['customer']['decided_on'] }}</p><p>Evidência: {{ $quote->data['customer']['evidence'] }}</p></section>@endif<p><small>Referência integral: {{ $quote->quote_key }} · Autor do evento {{ $quote->actor_id }} · {{ $quote->created_at }}</small></p><button type="button" onclick="window.print()">Imprimir esta versão</button></body></html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>SGF · Conferência de extracto QA</title><style>body{font:16px system-ui;max-width:1150px;margin:auto;padding:24px;background:#f4f7fa;color:#173b35}section{background:#fff;border:1px solid #d2dfda;border-radius:8px;padding:20px;margin:18px 0}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;max-width:100%;box-sizing:border-box}select,textarea{width:100%}button{cursor:pointer;margin:5px}button:disabled{cursor:default}fieldset{border:0;padding:0}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d2dfda;padding:10px;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#14557c}.scroll{overflow:auto}p{line-height:1.6}[hidden]{display:none!important}</style></head><body><nav><a href="/">Voltar à navegação SGF</a> · Financeiro · <a href="/sgf">Escolher outra função QA</a></nav><h1>Conferência de extracto QA</h1><p><strong>Extracto artificial interno, sem banco ou dinheiro real.</strong> Comparação limitada a duas linhas de liquidação existentes. Não altera a classificação da conta, cria pagamentos ou reabre Caixa/período.</p><p id="message" role="alert"></p>
|
||||
<section><h2>Extracto e correspondências</h2><div id="summary"></div><button id="refresh">Actualizar conferência</button><div id="download"></div><fieldset id="filters"><label>Linhas<select id="status"><option value="">Todas</option><option value="unmatched">Por propor</option><option value="pending">Por rever</option><option value="matched">Conferidas</option></select></label><button id="apply">Consultar</button><div class="scroll"><table><thead><tr><th>Referência/data</th><th>Débito MZN</th><th>Crédito MZN</th><th>Estado/correspondência</th></tr></thead><tbody id="rows"></tbody></table></div><h3>Histórico</h3><label>Linhas por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><div class="scroll"><table><thead><tr><th>Versão/autor</th><th>Estado/data</th><th>Fundamento</th><th>Conferência</th></tr></thead><tbody id="events"></tbody></table></div><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset><pre id="detail"></pre></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<textarea id="reason" required minlength="10" maxlength="4000"></textarea></label><button>Ver impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Cancelar a prévia</button><p id="receipt" role="status"></p></section>
|
||||
<section><h2>Recuperar referência</h2><form id="recovery"><label>Referência própria do pedido<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo histórico</button></form></section><script type="module" src="/sgf-bank.mjs"></script></body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>Financeiro · Caixa QA</title><style>body{font:16px system-ui;max-width:1150px;margin:auto;padding:24px;background:#f4f7fa;color:#142b3b}section{background:white;padding:20px;margin:18px 0;border:1px solid #ccd9e1;border-radius:10px}label{display:block;margin:10px 0}input,select,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}button{cursor:pointer;margin:5px}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #ddd}fieldset{border:0;padding:0}[role=alert]{color:#8b1e22;white-space:pre-wrap}.grid{display:flex;flex-wrap:wrap;gap:15px}dt{font-weight:bold}dd{margin-bottom:8px}</style></head><body>
|
||||
<a href="/">← Voltar ao SGF</a> · <a href="/sgf">Escolher função</a><h1>Financeiro · Caixa QA</h1><p>SIMULAÇÃO — SEM DINHEIRO REAL. Classificação de custódia explícita; uma factura ou recibo, por si só, não comprova numerário entregue. Não há ajuste automático nem reabertura.</p><p id="message" role="alert"></p>
|
||||
<section><h2>Caixa e origem actual</h2><div id="summary">A consultar…</div><button id="refresh">Actualizar conferência</button></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><label>Tipo<select id="kind" required><option value="">Escolher…</option></select></label><div id="selection"></div><div id="inputs"></div><label>Fundamento<input id="reason" required minlength="10" maxlength="4000"></label><button>Conferir impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Descartar prévia</button><p id="receipt" role="status"></p><form id="recovery"><label>Referência da confirmação<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo histórico sem repetir a operação</button></form><p>As origens confirmadas exigem preparação, revisão e aplicação individuais na <a href="/sgf">Contabilidade</a>. O fecho de Caixa recusa origens por valorizar; não lança automaticamente.</p></section>
|
||||
<section><h2>Extracto e histórico</h2><fieldset id="filters"><div class="grid"><label>Tipo<select id="filterKind"><option value="">Todos</option><option value="open">Abertura</option><option value="receipt">Recebimento</option><option value="expense">Despesa</option><option value="count">Contagem/fecho</option></select></label><label>Desde<input type="date" id="from"></label><label>Até<input type="date" id="to"></label><label>Por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label></div><button id="apply">Aplicar filtros</button><p id="period"></p><table><thead><tr><th>Versão / data</th><th>Tipo / acção</th><th>Estado</th><th>Valor/contagem</th><th>Autor / fundamento</th><th>Decisão</th></tr></thead><tbody id="events"></tbody></table><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset><p>O resumo do intervalo inclui todas as entradas e saídas confirmadas. O filtro por tipo limita o histórico; não altera o saldo da Caixa. A data é a do facto declarado; os instantes do histórico conservam a sua zona.</p></section><script type="module" src="/sgf-cash.mjs"></script></body></html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Financeiro · fornecedores · SGF</title><style>body{font:16px system-ui;background:#f3f7f5;color:#173e36;margin:0}main{max-width:1100px;margin:auto;padding:24px}section,article{background:white;border:1px solid #bed0c9;border-radius:8px;padding:18px;margin:16px 0}label{display:block;margin:12px 0}input,textarea,select,button{font:inherit;padding:10px;max-width:100%;box-sizing:border-box}input,textarea,select{display:block;width:100%}table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:8px;border-bottom:1px solid #ddd}a{color:#174d71}.notice{background:#fff3cf;padding:14px}</style></head><body><main id="finance-payables" data-context="{{ $context }}" data-csrf="{{ $csrf }}">
|
||||
@php($money=fn($n)=>$n===null?'Indeterminado':number_format($n/100,2,',',' ').' MZN')
|
||||
@php($prepare=$data['profile']==='finance_prepare') @php($review=$data['profile']==='finance_review')
|
||||
<a href="/">Voltar ao SGF</a> · <a href="/sgf">Trocar função QA</a><h1>Financeiro · obrigações a fornecedores</h1><p class="notice">Ensaio sintético, sem dinheiro real. A preparação e a confirmação são distintas. Confirmar um pagamento não reduz a dívida; a afectação confirmada reduz o saldo. A Contabilidade exige o seu próprio percurso.</p>
|
||||
<p role="status" id="result"></p>
|
||||
<section><h2>Obrigações originais</h2><table><thead><tr><th>Documento</th><th>Principal</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody>@foreach($data['obligations'] as $o)<tr><td>{{ $o['document_number'] }} · #{{ $o['id'] }}</td><td>{{ $money($o['principal_minor']) }}</td><td>{{ $money($o['balance_minor']) }}</td><td>{{ $o['due_on']??'Desconhecido; atraso indeterminado' }}</td></tr>@endforeach</tbody></table></section>
|
||||
@if($prepare&&count($data['obligations']))<section><h2>Preparar facto de pagamento</h2><form data-action="payment-prepare"><input type="hidden" name="currency" value="MZN"><label>Obrigação<select name="obligation_id" required data-source><option value="">Escolha explicitamente</option>@foreach($data['obligations'] as $o)<option value="{{ $o['id'] }}" data-revision="{{ $o['source_revision'] }}">{{ $o['document_number'] }} · {{ $money($o['balance_minor']) }}</option>@endforeach</select></label><input type="hidden" name="source_revision"><label>Montante (MZN)<input name="amount" required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"></label><label>Data do facto<input name="paid_on" type="date" required></label><label>Referência artificial<input name="fact_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}"></label><label>Evidência artificial<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}"></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Preparar — sem abater o saldo</button></form></section>@endif
|
||||
<section><h2>Pagamentos</h2>@foreach($data['money']['payments'] as $p)<article><h3>{{ $p['fact_reference'] }} · {{ $money($p['amount_minor']) }}</h3><p>Estado: {{ $p['state'] }} · {{ $p['reason'] }}</p>@if($review&&!$p['decision'])<form data-action="payment-decide"><input type="hidden" name="payment_id" value="{{ $p['id'] }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="confirmed">Confirmar o facto sintético</option><option value="rejected">Recusar</option></select></label><label>Evidência da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}"></label><label>Fundamento independente<textarea name="reason" required minlength="3"></textarea></label><button>Registar decisão individual</button></form>@endif</article>@endforeach
|
||||
@php($paymentPage=$data['money']['pagination']['payments'])<nav aria-label="Paginação dos pagamentos">Página {{ $paymentPage['page'] }} de {{ $paymentPage['last_page'] }} @if($paymentPage['page']>1)<a href="?payments_page={{ $paymentPage['page']-1 }}">Anterior</a>@endif @if($paymentPage['page']<$paymentPage['last_page'])<a href="?payments_page={{ $paymentPage['page']+1 }}">Seguinte</a>@endif</nav></section>
|
||||
@if($prepare)<section><h2>Propor afectação</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id" required><option value="">Escolha</option>@foreach($data['money']['payments'] as $p)@if($p['state']==='confirmed'&&$p['unallocated_minor']>0)<option value="{{ $p['id'] }}">{{ $p['fact_reference'] }} · Por afectar {{ $money($p['unallocated_minor']) }}</option>@endif @endforeach</select></label><label>Referência da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}"></label><label>Obrigação<select name="obligation_id" data-source required><option value="">Escolha</option>@foreach($data['obligations'] as $o)<option value="{{ $o['id'] }}" data-revision="{{ $o['source_revision'] }}">{{ $o['document_number'] }}</option>@endforeach</select></label><input type="hidden" name="source_revision"><label>Montante a afectar<input name="amount" required inputmode="decimal"></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Preparar afectação — sem a confirmar</button></form></section>@endif
|
||||
<section><h2>Conferência de afectações</h2>@foreach($data['money']['proposals'] as $p)<article><h3>{{ $p['reference'] }}</h3>@foreach(json_decode($p['plan'],true) as $line)<p>Obrigação #{{ $line['obligation_id'] }} · {{ $money($line['amount_minor']) }}</p>@endforeach<p>{{ $p['reason'] }} · {{ $p['decision']?->decision??'Por conferir' }}</p>@if($review&&!$p['decision'])<form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="{{ $p['id'] }}"><label>Decisão<select name="decision" required><option value="">Escolha</option><option value="confirmed">Confirmar a afectação mostrada</option><option value="rejected">Recusar</option></select></label><label>Fundamento<textarea name="reason" required minlength="3"></textarea></label><button>Revalidar saldos e confirmar individualmente</button></form>@endif</article>@endforeach</section>
|
||||
<section><h2>Afectações confirmadas e reversões</h2>@foreach($data['money']['allocations'] as $a)@php($reversed=collect($data['money']['reversals'])->contains(fn($r)=>$r->allocation_id===$a->id))@php($proposal=collect($data['money']['reversal_proposals'])->first(fn($r)=>$r->allocation_id===$a->id))<article><p>Afectação #{{ $a->id }} · Obrigação #{{ $a->obligation_id }} · {{ $money($a->amount_minor) }} · {{ $reversed?'Revertida':'Confirmada' }}</p>@if($prepare&&!$reversed&&!$proposal)@php($ob=collect($data['obligations'])->first(fn($o)=>$o['id']===$a->obligation_id))@if($ob)<form data-action="reversal-prepare"><input type="hidden" name="allocation_id" value="{{ $a->id }}"><input type="hidden" name="source_revision" value="{{ $ob['source_revision'] }}"><label>Fundamento da reversão<textarea name="reason" required minlength="3"></textarea></label><button>Preparar reversão sem apagar o original</button></form>@endif @endif @if($review&&$proposal&&!$reversed)<p>Proposta #{{ $proposal->id }} · {{ $proposal->reason }}</p><form data-action="reversal-confirm"><input type="hidden" name="proposal_id" value="{{ $proposal->id }}"><label>Fundamento independente<textarea name="reason" required minlength="3"></textarea></label><button>Confirmar esta reversão</button></form>@endif</article>@endforeach</section>
|
||||
<section><h2>Pesquisar e percorrer os registos</h2><form method="get"><label>Referência literal do pagamento<input name="payment_q" maxlength="100" value="{{ request('payment_q','') }}"></label><label>Estado<select name="payment_state">@foreach(['all'=>'Todos','proposed'=>'Por conferir','confirmed'=>'Confirmados','rejected'=>'Recusados'] as $value=>$label)<option value="{{ $value }}" @selected(request('payment_state','all')===$value)>{{ $label }}</option>@endforeach</select></label><label>Registos por página<select name="payment_per_page">@foreach([25,100] as $size)<option value="{{ $size }}" @selected((int)request('payment_per_page',25)===$size)>{{ $size }}</option>@endforeach</select></label><button>Pesquisar</button></form>
|
||||
@foreach(['page'=>['Obrigações',$data['pagination']],'payments_page'=>['Pagamentos',$data['money']['pagination']['payments']],'allocation_proposals_page'=>['Propostas de afectação',$data['money']['pagination']['allocation_proposals']],'allocations_page'=>['Afectações',$data['money']['pagination']['allocations']],'payment_events_page'=>['Histórico',$data['money']['pagination']['payment_events']]] as $parameter=>[$label,$p])<nav aria-label="{{ $label }}"><strong>{{ $label }}</strong> · Página {{ $p['page'] }} de {{ $p['last_page'] }} @if($p['page']>1)<a href="{{ request()->fullUrlWithQuery([$parameter=>$p['page']-1]) }}">Anterior</a>@endif @if($p['page']<$p['last_page'])<a href="{{ request()->fullUrlWithQuery([$parameter=>$p['page']+1]) }}">Seguinte</a>@endif</nav>@endforeach
|
||||
</section><section><h2>Histórico financeiro</h2>@foreach($data['money']['events'] as $event)<article><p>Evento #{{ $event->id }} · {{ $event->created_at }}</p><p>{{ $event->kind??$event->action??'Registo auditado' }}</p></article>@endforeach</section>
|
||||
</main><script type="module" src="/sgf-finance-payables.mjs"></script></body></html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>SGF · Indicadores QA</title><style>body{font:16px system-ui;max-width:1150px;margin:auto;padding:24px;background:#f4f7fa;color:#173b35}section{background:#fff;border:1px solid #d2dfda;border-radius:8px;padding:20px;margin:18px 0}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;max-width:100%;box-sizing:border-box}select,textarea{width:100%}button{cursor:pointer;margin:5px}button:disabled{cursor:default}fieldset{border:0;padding:0}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d2dfda;padding:10px;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#14557c}.scroll{overflow:auto}p{line-height:1.6}[hidden]{display:none!important}</style></head><body><nav><a href="/">Voltar à navegação SGF</a> · Financeiro · <a href="/sgf">Escolher outra função QA</a></nav><h1>Critérios e indicadores financeiros QA</h1><p><strong>Hipóteses limitadas do exercício — não são rácios gerais de liquidez/margem ou recomendação de pagamento.</strong> Escolha e revisão de fontes não atribuem critérios automaticamente a outras contas/empresas. Sem série anterior validada, a tendência fica indeterminada.</p><p id="message" role="alert"></p>
|
||||
<section><h2>Fontes conciliadas</h2><div id="summary"></div><h2>Catálogo e histórico</h2><fieldset id="filters"><label>Consulta<select id="view"><option value="criteria">Critérios actuais</option><option value="history">Histórico de um indicador</option><option value="results">Resultados guardados</option></select></label><label>Indicador<select id="filterMetric"><option value="">Todos</option></select></label><label>Estado<select id="filterState"><option value="">Todos</option><option value="prepared">Preparado</option><option value="reviewed">Revisto</option><option value="refused">Recusado</option></select></label><label>Nome/evidência<input id="search" maxlength="100"></label><label>Linhas por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><button id="apply">Consultar</button><button id="refresh">Actualizar</button><div class="scroll"><table><thead><tr><th>Registo</th><th>Versão/estado</th><th>Retrato/resultado</th><th>Conferência</th></tr></thead><tbody id="events"></tbody></table></div><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset><p id="selection">Nenhuma versão escolhida.</p><pre id="detail"></pre><div id="download"></div></section>
|
||||
<section><h2>Operação individual</h2><form id="operation"><fieldset id="fields"><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<textarea id="reason" required minlength="10" maxlength="4000"></textarea></label><button>Ver impacto antes de confirmar</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta operação QA</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Cancelar a prévia</button><p id="receipt" role="status"></p></section>
|
||||
<section><h2>Recuperar referência</h2><form id="recovery"><label>Referência própria do pedido<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo histórico</button></form></section><script type="module" src="/sgf-indicators.mjs"></script></body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>SGF · Regras fiscais QA</title><style>body{font:16px system-ui;max-width:1150px;margin:auto;padding:24px;background:#f4f7fa;color:#173b35}section{background:#fff;border:1px solid #d2dfda;border-radius:8px;padding:20px;margin:18px 0}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;max-width:100%;box-sizing:border-box}select,textarea{width:100%}button{cursor:pointer;margin:5px}button:disabled{cursor:default}fieldset{border:0;padding:0}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d2dfda;padding:10px;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#14557c}.scroll{overflow:auto}p{line-height:1.6}[hidden]{display:none!important}</style></head><body>
|
||||
<nav><a href="/">Voltar à navegação SGF</a> · Contabilidade · <a href="/sgf">Escolher outra função QA</a></nav><h1>Regras fiscais versionadas QA</h1><p><strong>Ensaio artificial, sem activação legal.</strong> As taxas16% e17% deste contrato não representam uma mudança legislativa. Referências não conferidas ficam pendentes. Os cálculos por linhas não emitem factura, dívida, pagamento ou lançamento. Nenhuma regra é atribuída automaticamente a serviços, contribuintes ou documentos.</p><p id="message" role="alert"></p>
|
||||
<section><h2>Catálogo e histórico</h2><fieldset id="filters"><label>Consulta<select id="view"><option value="rules">Regras e versões</option><option value="history">Histórico de uma chave</option><option value="calculations">Cálculos guardados</option></select></label><label>Chave para histórico (obrigatória nessa consulta)<input id="filterKey" maxlength="80"></label><label>Código, nome ou fonte<input id="search" maxlength="100"></label><label>Família<select id="family"><option value="">Todas</option><option value="qa">QA artificial</option><option value="iva">IVA</option><option value="irps">IRPS</option><option value="irpc">IRPC</option><option value="ispc">ISPC</option><option value="ice">ICE</option><option value="inss">INSS</option><option value="other">Outros</option></select></label><label>Estado<select id="filterState"><option value="">Todos</option><option value="prepared">Preparado</option><option value="reviewed">Revisto</option><option value="refused">Recusado</option><option value="withdrawn">Retirado</option></select></label><label>Linhas por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><button id="apply">Consultar</button><button id="refresh">Actualizar</button><div class="scroll"><table><thead><tr><th>Registo</th><th>Versão/estado</th><th>Fonte/vigência ou resultado</th><th>Conferência</th></tr></thead><tbody id="events"></tbody></table></div><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset><h3>Versão escolhida</h3><p id="selection">Nenhuma versão escolhida.</p><pre id="detail"></pre><div id="documentDownload"></div></section>
|
||||
<section><h2>Operação individual</h2><form id="operation"><fieldset id="fields"><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<textarea id="reason" required minlength="10" maxlength="4000"></textarea></label><button>Ver impacto antes de confirmar</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta operação QA</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Cancelar a prévia</button><p id="receipt" role="status"></p></section>
|
||||
<section><h2>Recuperar referência</h2><form id="recovery"><label>Referência do pedido desta função<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo histórico</button></form></section><script type="module" src="/sgf-fiscal-rules.mjs"></script></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>SGF · Livro e mapa interno de IVA QA</title><style>body{font:16px system-ui;max-width:1150px;margin:auto;padding:24px;background:#f4f7fa;color:#173b35}section{background:#fff;border:1px solid #d2dfda;border-radius:8px;padding:20px;margin:18px 0}label{display:block;margin:12px 0}input,select,textarea,button{font:inherit;padding:9px;max-width:100%;box-sizing:border-box}select,textarea{width:100%}button{cursor:pointer;margin:5px}button:disabled{cursor:default}fieldset{border:0;padding:0}table{border-collapse:collapse;width:100%}td,th{border:1px solid #d2dfda;padding:10px;text-align:left;vertical-align:top}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#14557c}.scroll{overflow:auto}p{line-height:1.6}[hidden]{display:none!important}</style></head><body>
|
||||
<nav><a href="/">Voltar à navegação SGF</a> · Contabilidade · <a href="/sgf">Escolher outra função QA</a></nav><h1>Livro e mapa interno de IVA QA</h1><p><strong>Ensaio sintético — sem validade fiscal.</strong> Apenas a origem escolhida; não é apuramento integral da empresa ou declaração oficial. Dedução e imposto a pagar ficam indeterminados.</p><p id="message" role="alert"></p>
|
||||
<section><h2>Estado actual</h2><div id="summary"></div><button id="refresh">Actualizar conferência</button><div id="download"></div></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><label>Objecto<select id="kind" required><option value="">Escolher…</option><option value="assessment">Livro de saídas QA</option><option value="form">Mapa interno QA</option></select></label><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<textarea id="reason" required minlength="10" maxlength="4000"></textarea></label><button id="prepare">Ver impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Cancelar a prévia</button><p id="receipt" role="status"></p></section>
|
||||
<section><h2>Recuperar referência</h2><form id="recovery"><label>Referência do pedido desta função<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo histórico</button></form></section>
|
||||
<section><h2>Histórico e fundamentos</h2><fieldset id="filters"><label>Objecto<select id="filterKind"><option value="">Todos os autorizados</option><option value="assessment">Livro</option><option value="form">Mapa</option></select></label><label>Linhas por página<select id="size"><option>25</option><option>100</option></select></label><button id="apply">Consultar</button><div class="scroll"><table><thead><tr><th>Versão e autor</th><th>Estado/data</th><th>Fundamento</th><th>Conferência</th></tr></thead><tbody id="events"></tbody></table></div><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset><pre id="detail"></pre></section>
|
||||
<script type="module" src="/sgf-iva.mjs"></script></body></html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SGF Tempo — módulos e funções</title>
|
||||
<style>body{font:16px system-ui;background:#f4f7f8;color:#173e36;margin:0}main{max-width:1180px;margin:auto;padding:28px}a{color:#174d71}p{line-height:1.6}.brand{font-size:1.8rem;letter-spacing:.04em}.module-choices{display:flex;gap:8px;flex-wrap:wrap;background:#fff;padding:12px;border:1px solid #d8e2df;border-radius:10px;margin:24px 0 32px}.module-choice{position:relative;flex:1;min-width:140px}.module-choice>summary{list-style:none;display:flex;justify-content:space-between;align-items:center;gap:20px;padding:14px 12px;cursor:pointer;font-weight:650;border-radius:7px;background:#edf4f1}.module-choice>summary::-webkit-details-marker{display:none}.module-choice[open]>summary{background:#1c5a4c;color:#fff}.function-dropdown{position:absolute;z-index:20;top:calc(100% + 8px);left:0;width:300px;max-width:85vw;max-height:65vh;overflow:auto;background:#fff;padding:12px;border:1px solid #ccdcd5;box-shadow:0 14px 32px #173e3626;border-radius:9px}.module-choice:last-child .function-dropdown{left:auto;right:0}.function-dropdown h3{font-size:.85rem;color:#63776e;margin:16px 8px 8px}.function-dropdown p{font-size:.88rem;margin:8px}.function-dropdown button{display:block;text-align:left;width:100%;font:inherit;padding:12px 10px;cursor:pointer;background:#fff;color:#173e36;border:0;border-radius:6px}.function-dropdown button:hover,.function-dropdown button:focus-visible{background:#e6f0eb}.function-dropdown .consult{background:#e6f0eb;font-weight:650}.intro{max-width:780px}.note{border-left:4px solid #39816c;padding:12px 18px;background:#eaf3ef}.advanced{margin-top:24px}.advanced>summary{cursor:pointer;padding:14px;background:#e8edef;border-radius:8px}.profiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-top:16px}.profiles article{background:#fff;border:1px solid #d8e2df;border-radius:8px;padding:18px}.profiles h3{font-size:1rem}.profiles select,.profiles button{font:inherit;padding:10px;width:100%;margin-top:12px}@media(max-width:760px){main{padding:18px}.module-choice{flex:1 1 28%;min-width:110px}.module-choice>summary{padding:12px 8px;gap:8px}.function-dropdown{position:fixed;left:16px!important;right:16px!important;width:auto;max-width:none;top:auto;max-height:60vh}.module-choices{gap:6px;padding:8px}}</style>
|
||||
</head><body><main>
|
||||
<header><strong class="brand">TEMPO · SGF</strong><h1>Escolha o módulo</h1><p class="intro">Abra o dropdown do módulo e escolha <strong>Consultar</strong> ou a função com que pretende trabalhar. Cada entrada mantém a sua identidade e permissões.</p></header>
|
||||
<nav class="module-choices" aria-label="Módulos do SGF">
|
||||
@foreach($tour as $module=>$route)
|
||||
<details class="module-choice" name="sgf-entry-module"><summary>{{ $route['label'] }} <span aria-hidden="true">▾</span></summary><div class="function-dropdown" aria-label="Funções de {{ $route['label'] }}">
|
||||
<form method="post" action="/sgf/entrar" data-presentation-module="{{ $module }}">@csrf<input type="hidden" name="profile" value="{{ $route['profile'] }}"><input type="hidden" name="module_id" value="{{ $module }}"><input type="hidden" name="landing" value="{{ $module }}"><button class="consult" type="submit">Consultar {{ $route['label'] }} →</button></form>
|
||||
<p>{{ $route['description'] }}</p><p>Consulta com {{ $route['function'] }}.</p><h3>Funções de trabalho</h3>
|
||||
@foreach($moduleProfiles[$module]??[] as $key=>$profile)<form method="post" action="/sgf/entrar">@csrf<input type="hidden" name="profile" value="{{ $key }}"><input type="hidden" name="module_id" value="{{ $module }}"><button type="submit">{{ $profile['label'] }}</button></form>@endforeach
|
||||
</div></details>
|
||||
@endforeach
|
||||
</nav>
|
||||
<section class="note"><strong>Apresentação local com dados fictícios</strong><p>Comercial, Compras, Stock, Oficinas, Financeiro e Contabilidade@if(isset($tour['human_resources'])) e Recursos Humanos@endif usam a mesma instalação. Consultar não repete operações. Preparação e revisão exigem funções distintas; nenhuma entrada concede direitos novos.</p></section>
|
||||
<section aria-labelledby="sgf-information"><h2 id="sgf-information">O que pode acompanhar em cada módulo</h2><p>Comece pela consulta. Dentro da função escolhida, use <strong>Início</strong> para ver o percurso, consultar os registos existentes e abrir documentos e histórico. As listas respeitam as permissões da função e a empresa/unidade seleccionadas.</p><div class="profiles">@foreach($tour as $module=>$route)<article><h3>{{ $route['label'] }}</h3><p>{{ $route['description'] }}.</p><form method="post" action="/sgf/entrar">@csrf<input type="hidden" name="profile" value="{{ $route['profile'] }}"><input type="hidden" name="module_id" value="{{ $module }}"><button type="submit">Abrir início de {{ $route['label'] }}</button></form></article>@endforeach</div></section>
|
||||
<p class="intro">Documento, movimento físico, dívida e lançamento têm evidências distintas. Preparar não equivale a aprovar ou pagar; cada continuação mantém a origem e exige a função responsável.</p>
|
||||
<p><a href="/compras">Entrada anterior de Compras</a></p>
|
||||
<details class="advanced"><summary>Todos os perfis e módulos autorizados</summary><div class="profiles">@foreach($profiles as $key=>$profile)<article><h3>{{ $profile['label'] }}</h3><form method="post" action="/sgf/entrar">@csrf<input type="hidden" name="profile" value="{{ $key }}"><label>Módulo<select name="module_id" required><option value="">Escolha o módulo</option>@foreach($profile['modules'] as $module)<option value="{{ $module['id'] }}">{{ $module['label'] }}</option>@endforeach</select></label><button type="submit">Entrar nesta função</button></form></article>@endforeach</div></details>
|
||||
<script type="module" src="/sgf-entry-menu.mjs"></script>
|
||||
</main></body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>Stock · Artigos e revisão</title><style>body{font:16px system-ui;color:#173e36;background:#f4f7f8;max-width:1180px;margin:auto;padding:24px}section{background:#fff;border:1px solid #d8e2df;border-radius:10px;padding:20px;margin:20px 0}p{line-height:1.6}label{display:block;margin:12px 0}button,input,select{font:inherit;padding:10px;max-width:100%;box-sizing:border-box}button{margin:5px;cursor:pointer}fieldset{border:0;padding:0}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{padding:10px;text-align:left;border-bottom:1px solid #ddd}small{display:block}pre{white-space:pre-wrap;overflow-wrap:anywhere}[role=alert]{color:#8b1e22}dt{font-weight:650}dd{margin:8px 0 18px}a{color:#174d71}</style></head><body>
|
||||
<header><a href="/">← Voltar ao SGF</a> · <a href="/sgf">Escolher função</a><h1>Artigos de Stock</h1><p>Consultar o catálogo, preparar um artigo e submetê-lo a revisão distinta. Só a aprovação disponibiliza o novo artigo; não cria saldo inicial, movimento, lote ou custo.</p></header><p id="message" role="alert"></p>
|
||||
<section><h2>Catálogo e processos</h2><fieldset id="filters"><div class="grid"><label>Consulta<select id="view"><option value="items">Artigos existentes</option><option value="proposals">Processos actuais</option><option value="history">Histórico do processo escolhido</option></select></label><label>Código ou designação<input id="search" type="search" maxlength="100"></label><label>Estado do processo<select id="state"><option value="">Todos</option><option value="prepared">Preparado</option><option value="approved">Aprovado</option><option value="refused">Recusado</option></select></label><label>Registos por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label></div><button id="apply">Pesquisar</button><button id="refresh">Actualizar consulta</button><button id="new" hidden>Preparar novo artigo</button><p id="page" role="status"></p><div class="table-scroll"><table><thead><tr><th>Código / designação</th><th>Unidade</th><th>Origem / estado</th><th>Versão</th><th>Consultar</th></tr></thead><tbody id="records"></tbody></table></div><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset></section>
|
||||
<section><h2>Ficha e decisão</h2><p id="selection">Escolha explicitamente um artigo/processo ou prepare um novo.</p><pre id="detail"></pre><form id="operation"><fieldset id="fields"><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<textarea id="reason" required minlength="10" maxlength="2000"></textarea></label><button>Conferir impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Descartar prévia</button><p id="receipt" role="status"></p><button id="discard" type="button">Descartar campos não guardados</button></section>
|
||||
<section><h2>Recuperar uma decisão</h2><form id="recovery"><label>Referência do pedido<input id="referenceKey" required maxlength="36"></label><button>Consultar recibo próprio</button></form><p>Recuperar um recibo histórico não repete a operação. Código e unidade de artigos já criados são imutáveis; apenas a designação de artigos deste percurso pode ser corrigida por nova revisão. Artigos anteriores permanecem só para consulta.</p></section>
|
||||
<p>Instalação QA com dados fictícios. Nenhuma compra, dívida, pagamento ou lançamento é gerado por este cadastro.</p><script type="module" src="/sgf-stock-catalogue.mjs"></script></body></html>
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>Stock · políticas e devoluções</title><style>body{font:16px system-ui;max-width:1100px;margin:auto;padding:24px;color:#142b3b;background:#f4f7fa}section{background:white;padding:20px;margin:18px 0;border:1px solid #ccd9e1;border-radius:10px}label{display:block;margin:10px 0}input,select,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}button{margin:5px;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{padding:9px;text-align:left;border-bottom:1px solid #ddd}fieldset{border:0;padding:0}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px}[role=alert]{color:#8b1e22;white-space:pre-wrap}dt{font-weight:bold}dd{margin-bottom:10px}</style></head><body>
|
||||
<header><a href="/">← Voltar ao SGF</a> · <a href="/sgf">Escolher função</a><h1>Stock · políticas e devoluções ao fornecedor</h1><p>Recorte QA autorizado. Política, aprovação e execução são decisões distintas. A devolução física não cria nota de crédito nem altera a dívida ao fornecedor.</p></header>
|
||||
<p id="message" role="alert"></p><section><h2>Origem e saldo actual</h2><div id="summary">A consultar…</div></section>
|
||||
<section><h2>Consultar processos</h2><fieldset id="filters"><label>Percurso<select id="family"><option value="policy">Políticas e mínimos</option><option value="supplier_return">Devolução ao fornecedor</option></select></label><label>Estado<select id="state"><option value="">Todos</option><option value="draft">Rascunho</option><option value="approved">Aprovado</option><option value="returned">Devolvido para revisão</option><option value="suspended">Suspenso</option><option value="executed">Executado</option></select></label><label>Registos por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><button id="refresh">Actualizar conferência</button><button id="new">Novo processo</button><button id="all">Voltar aos processos actuais</button><table><thead><tr><th>Processo</th><th>Versão</th><th>Estado</th><th>Conferência</th></tr></thead><tbody id="records"></tbody></table><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></fieldset></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><p id="selected">Escolha um processo ou prepare um novo.</p><label>Acção<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<input id="reason" required minlength="10" maxlength="4000"></label><button>Conferir impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Descartar prévia</button><p id="receipt" role="status"></p></section>
|
||||
<script type="module" src="/sgf-stock-completion.mjs"></script></body></html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ $csrf }}"><meta name="sgf-context" content="{{ $context }}"><title>Stock · trânsito e inventário</title><style>body{font:16px system-ui;max-width:1100px;margin:auto;padding:24px;background:#f4f7fa;color:#142b3b}section{background:white;padding:20px;margin:18px 0;border:1px solid #ccd9e1;border-radius:10px}label{display:block;margin:12px 0}input,select,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}button{cursor:pointer;margin:5px}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:9px;border-bottom:1px solid #ddd}dt{font-weight:600}dd{margin-bottom:8px}fieldset{border:0;padding:0}.row{display:flex;gap:12px;flex-wrap:wrap}[role=alert]{white-space:pre-wrap;color:#8b1e22}a{color:#075778}</style></head>
|
||||
<body><header><a href="/sgf">← Mudar função ou módulo</a> · <a href="/">Voltar ao SGF</a><h1>Stock · transferências e inventário</h1><p>Operações sintéticas no mesmo livro físico. A contagem não aplica diferenças automaticamente. A transferência mantém propriedade e só disponibiliza no destino a quantidade efectivamente recebida.</p></header>
|
||||
<p id="message" role="alert"></p><section><h2>Estado actual</h2><div id="summary">A consultar…</div><button id="refresh">Actualizar conferência</button></section>
|
||||
<section><h2>Decisão individual</h2><form id="operation"><fieldset id="fields"><label>Operação<select id="action" required><option value="">Escolher…</option></select></label><div id="inputs"></div><label>Fundamento<input id="reason" minlength="10" maxlength="4000" required></label><button type="submit">Conferir impacto</button></fieldset></form><div id="impact"></div><button id="confirm" hidden>Confirmar esta decisão</button><button id="retry" hidden>Recuperar a mesma confirmação</button><button id="cancel" hidden>Descartar a prévia</button><p id="receipt" role="status"></p></section>
|
||||
<section><h2>Transferências</h2><label>Pesquisar referência<input id="search" maxlength="100"></label><label>Registos por página<select id="size"><option value="25">25</option><option value="100">100</option></select></label><table><thead><tr><th>Referência</th><th>Enviado</th><th>Por receber/devolver</th></tr></thead><tbody id="transfers"></tbody></table><p id="page"></p><button id="previous">Anterior</button><button id="next">Seguinte</button></section>
|
||||
<section><h2>Lotes e conferências</h2><div id="lots"></div><p>Uma retenção ou alteração de identificação invalida a conferência anterior. A libertação e o ajuste exigem outra pessoa. O histórico conserva a identificação original e cada decisão posterior.</p></section>
|
||||
<section><h2>Histórico auditado</h2><div id="history"></div><p id="historyPage"></p><button id="historyPrevious">Anterior</button><button id="historyNext">Seguinte</button></section>
|
||||
<script type="module" src="/sgf-stock-final.mjs"></script></body></html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Operações de Stock · SGF</title>
|
||||
<style>body{font:16px system-ui;color:#183d37;background:#f3f6f4;margin:0}main{max-width:58rem;margin:auto;padding:1.5rem}section{background:white;padding:1.5rem;margin:1rem 0;border-radius:.6rem}label{display:block;margin:.8rem 0}input,select,button{font:inherit;padding:.6rem;max-width:100%;box-sizing:border-box}input,select{display:block;width:100%}button{cursor:pointer;margin:.3rem}button:disabled{cursor:wait}fieldset{border:0;padding:0}dt{font-weight:bold;margin-top:.7rem}dd{margin:.2rem 0}table{width:100%;border-collapse:collapse}td,th{text-align:left;border-bottom:1px solid #ddd;padding:.5rem}.status{white-space:pre-line}a{color:#1a6155}</style></head>
|
||||
<body><main id="stock-workspace" data-context="{{ $context }}" data-csrf="{{ $csrf }}">
|
||||
<a href="/">Voltar ao SGF</a><h1>Operações de Stock</h1><p>Saída para utilização, transferência ou inventário físico nos armazéns autorizados. A transferência regista saída e entrada na mesma confirmação; não representa trânsito. A contagem observa a quantidade; aplicar a diferença exige uma confirmação própria. Artigos por lotes mantêm o percurso específico de contagem por lote.</p>
|
||||
<p role="status" aria-live="polite" class="status" id="status"></p>
|
||||
<section><h2>Preparar operação</h2><form id="operation"><fieldset id="fields">
|
||||
<label>Operação<select name="kind"><option value="issue">Saída para utilização</option><option value="transfer">Transferência</option><option value="count">Registar contagem física</option><option value="adjustment">Aplicar diferença da contagem</option></select></label>
|
||||
<label>Referência<input name="reference" required maxlength="100"></label>
|
||||
<div id="pickers"></div>
|
||||
<label id="destination-field">Destino de utilização<input name="destination" maxlength="200"></label>
|
||||
<label id="quantity-field">Quantidade<input name="quantity" required inputmode="decimal" pattern="[0-9]{1,9}(\.[0-9]{1,3})?" placeholder="Ex.: 0.500"></label>
|
||||
<label>Fundamento<input name="reason" required minlength="10" maxlength="4000"></label>
|
||||
<button type="submit">Conferir impacto</button></fieldset></form></section>
|
||||
<section id="impact-section" hidden><h2>Impacto a confirmar</h2><dl id="impact"></dl><button id="confirm" type="button">Confirmar esta operação</button><p>Em caso de resposta incerta, conserve este ecrã e recupere a mesma operação pelo botão de confirmação. Não crie outra referência.</p></section>
|
||||
<section><h2>Histórico</h2><button type="button" id="refresh">Actualizar histórico</button><div id="history"></div><button type="button" id="older">Página seguinte</button><button type="button" id="newer">Página anterior</button></section>
|
||||
</main><script type="module" src="/stock-operations-workspace.mjs"></script></body></html>
|
||||
Reference in New Issue
Block a user