Preserva entrega A aceite de servicos em rascunho

This commit is contained in:
2026-10-25 22:02:44 +02:00
parent 26b80df545
commit ca13acb74e
31 changed files with 1152 additions and 17 deletions
+106
View File
@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
export const serviceAreas = { clients: 'Clientes', services: 'Serviços', service_drafts: 'Prestações em rascunho', billing_drafts: 'Rascunhos de facturação' };
const actions = { view: 'Consultar', create: 'Criar', edit: 'Editar', manage: 'Gerir concessões' };
const isDraft = area => area === 'service_drafts' || area === 'billing_drafts';
const newLine = () => ({ service_id: '', quantity: '', unit_price: '', description: '' });
export default function ServicesWorkspace({ area, context, can, api, perform, busy, initialId, navigate }) {
const unit = isDraft(area) ? context.unit?.id ?? null : null;
const allowed = action => can(`${area}.${action}`, unit);
const [records, setRecords] = useState([]);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState(null);
const [references, setReferences] = useState({ clients: [], services: [], source: [] });
const [error, setError] = useState('');
const [key, setKey] = useState(() => crypto.randomUUID());
const [access, setAccess] = useState(false);
const path = `/api/a/${area}`;
async function load() { const result = await api(path); setRecords(result.records); }
async function open(id) { setError(''); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } }
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); }, [area, context.token]);
async function start(record = null) {
setError(''); setAccess(false);
try {
if (isDraft(area)) {
const [clients, services, source] = await Promise.all([
api('/api/a/clients'), api('/api/a/services'), area === 'billing_drafts' ? api('/api/a/service_drafts') : Promise.resolve({ records: [] })
]);
setReferences({ clients: clients.records, services: services.records, source: source.records });
}
setSelected(record);
setForm(record ? { name: record.name, description: record.data.description || '', client_id: record.client_id || '', source_id: record.source_id || '', currency: record.data.currency || '', lines: record.data.lines?.map(line => ({ ...line, quantity: line.quantity ?? '', unit_price: line.unit_price ?? '' })) || [newLine()] }
: { name: '', description: '', client_id: '', source_id: '', currency: '', lines: [newLine()] });
setKey(crypto.randomUUID());
} catch (e) { setError(e.message); }
}
async function save(event) {
event.preventDefault();
let body = { name: form.name.trim() };
if (area === 'services') body.description = form.description || null;
if (isDraft(area)) {
body = { ...(form.name.trim() ? { name: form.name.trim() } : {}), description: form.description || null };
if (area === 'billing_drafts' && !selected) body.source_id = Number(form.source_id);
else {
body.currency = form.currency || null;
body.lines = form.lines.map(line => ({ service_id: Number(line.service_id), description: line.description || null, quantity: line.quantity || null, unit_price: line.unit_price || null }));
if (area === 'service_drafts') body.client_id = Number(form.client_id);
}
}
if (selected) body.version = selected.version;
await perform(async () => {
const result = await api(selected ? `${path}/${selected.id}` : path, selected ? 'PATCH' : 'POST', body, context.token, key);
await load(); await open(result.id); setKey(crypto.randomUUID());
}, 'Rascunho ou cadastro guardado com histórico e captura durável.');
}
async function prepareBilling() {
await perform(async () => {
const result = await api('/api/a/billing_drafts', 'POST', { source_id: selected.id }, context.token, key);
navigate('billing_drafts', result.id);
}, 'Rascunho de facturação preparado a partir da prestação. Nenhuma factura foi emitida.');
}
function changeLine(index, field, value) { setForm({ ...form, lines: form.lines.map((line, n) => n === index ? { ...line, [field]: value } : line) }); }
if (!allowed('view')) return <section className="panel"><h1>{serviceAreas[area]}</h1><p>Sem consulta autorizada neste contexto.</p></section>;
return <>
<div className="a-heading"><div><p className="eyebrow">ENTREGA A · PREPARAÇÃO</p><h1>{serviceAreas[area]}</h1><p className="muted">{isDraft(area) ? `Unidade: ${context.unit?.name || 'não seleccionada'}` : 'Cadastro partilhado pelas unidades da empresa'}</p></div><div className="actions">{allowed('create') && <button className="primary" disabled={busy} onClick={() => start()}>Novo registo</button>}{allowed('manage') && <button onClick={() => { setAccess(!access); setForm(null); }}>Gerir acessos desta área</button>}</div></div>
{error && <div role="alert" className="alert error">{error}</div>}
{isDraft(area) && <div className="a-draft-notice">Apenas rascunhos. Sem emissão fiscal, confirmação de serviço ou lançamentos contabilísticos.</div>}
{access ? <AreaAccess area={area} context={context} api={api} perform={perform} busy={busy} /> : <>
<div className="a-grid"><section className="panel a-list"><h2>Registos</h2>{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.map(record => <button key={record.id} className={selected?.id === record.id ? 'chosen' : ''} onClick={() => open(record.id)}><strong>{record.name}</strong><small>#{record.id} · Versão {record.version}{isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
{form ? <form onSubmit={save}><h2>{selected ? 'Editar' : 'Criar'} {isDraft(area) ? 'rascunho' : 'cadastro'}</h2><label>{area === 'clients' ? 'Nome do cliente' : area === 'services' ? 'Nome do serviço' : 'Designação do rascunho (opcional)'}<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} maxLength={255} required={!isDraft(area)} /></label>
{area !== 'clients' && <label>Descrição (opcional)<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} maxLength={2000} /></label>}
{area === 'billing_drafts' && !selected ? <><label>Prestação de origem<select value={form.source_id} onChange={e => setForm({ ...form, source_id: e.target.value })} required><option value="">Seleccionar prestação em rascunho</option>{references.source.map(record => <option key={record.id} value={record.id}>{record.name} · versão {record.version}</option>)}</select></label><p className="help">Cliente, linhas e moeda serão reutilizados da prestação, sem redigitação. Poderá editar o rascunho depois de o preparar.</p></> : isDraft(area) && <>
{area === 'service_drafts' && <label>Cliente<select value={form.client_id} onChange={e => setForm({ ...form, client_id: e.target.value })} required><option value="">Seleccionar cliente da empresa</option>{references.clients.map(client => <option key={client.id} value={client.id}>{client.name}</option>)}</select></label>}
<label>Moeda indicada (opcional)<input value={form.currency} placeholder="Código de 3 letras; vazio = pendente" maxLength={3} pattern="[A-Z]{3}" onChange={e => setForm({ ...form, currency: e.target.value.toUpperCase() })} /></label>
<p className="help">Quantidades e preços usam ponto decimal, até 16 algarismos inteiros e 8 decimais. Campos vazios geram pendências. O imposto continua por validar.</p>
{form.lines.map((line, index) => <fieldset className="a-line" key={index}><legend>Linha {index + 1}</legend><label>Serviço<select value={line.service_id} onChange={e => changeLine(index, 'service_id', e.target.value)} required disabled={area === 'billing_drafts'}><option value="">Seleccionar serviço</option>{references.services.map(service => <option key={service.id} value={service.id}>{service.name}</option>)}</select></label><div className="form-grid"><label>Quantidade (opcional)<input inputMode="decimal" value={line.quantity} onChange={e => changeLine(index, 'quantity', e.target.value)} placeholder="Ex.: 1.25" /></label><label>Preço proposto (opcional)<input inputMode="decimal" value={line.unit_price} onChange={e => changeLine(index, 'unit_price', e.target.value)} placeholder="Sem moeda presumida" /></label></div>{area === 'service_drafts' && form.lines.length > 1 && <button type="button" onClick={() => setForm({ ...form, lines: form.lines.filter((_, n) => n !== index) })}>Retirar linha do rascunho</button>}</fieldset>)}
{area === 'service_drafts' && form.lines.length < 100 && <button type="button" onClick={() => setForm({ ...form, lines: [...form.lines, newLine()] })}>Adicionar linha</button>}
</>}
<div className="actions a-form-actions"><button className="primary" disabled={busy}>Guardar {isDraft(area) ? 'rascunho' : 'cadastro'}</button><button type="button" onClick={() => setForm(null)}>Cancelar edição</button></div>
</form> : selected ? <><div className="a-heading"><h2>{selected.name}</h2>{allowed('edit') && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Estado: rascunho' : ''}</p>
{selected.data.description && <p>{selected.data.description}</p>}
{isDraft(area) && <><p><strong>Cliente:</strong> {selected.data.client_name}</p>{selected.source_id && <p>Origem: prestação #{selected.source_id}, versão {selected.data.source_version}</p>}<p><strong>Moeda:</strong> {selected.data.currency || 'Não indicada — pendente'}</p><div className="table-scroll"><table><thead><tr><th>Serviço</th><th>Quantidade</th><th>Preço proposto</th></tr></thead><tbody>{selected.data.lines.map((line, i) => <tr key={i}><td>{line.service_name}</td><td>{line.quantity ?? 'Por preencher'}</td><td>{line.unit_price ?? 'Por preencher'}</td></tr>)}</tbody></table></div><p><strong>Base aritmética:</strong> {selected.base_amount ?? 'Incompleta'} {selected.data.currency || '(moeda pendente)'}</p><p className="help">Esta base não é um total fiscal ou valor definitivo a pagar. Imposto por validar; contabilidade não processada.</p>
{Object.keys(selected.pending).length > 0 && <div className="a-pending"><h3>Pendências</h3><ul>{Object.entries(selected.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul></div>}
{area === 'service_drafts' && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
{area === 'billing_drafts' && allowed('edit') && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
</>}
<details className="a-history"><summary>Histórico e captura durável ({selected.history?.length || 0} versões)</summary>{selected.history?.map(entry => <div key={entry.id}><strong>Versão {entry.version}</strong><p className="help">{entry.created_at} · Autor #{entry.actor_id} · Evento #{entry.event?.id} · Capturado, sem processamento contabilístico</p><details><summary>Dados preservados nesta versão</summary><pre>{JSON.stringify(entry.snapshot, null, 2)}</pre></details></div>)}</details>
</> : <><h2>Seleccione um registo</h2><p>Os dados e o histórico aparecem aqui. As operações disponíveis dependem das suas concessões explícitas.</p></>}
</section></div></>}
</>;
}
function AreaAccess({ area, context, api, perform, busy }) {
const [state, setState] = useState(null); const [error, setError] = useState('');
const [email, setEmail] = useState(''); const [action, setAction] = useState('view'); const [reason, setReason] = useState('');
const path = `/api/a/${area}/access`;
async function load() { setState(await api(path)); }
useEffect(() => { load().catch(e => setError(e.message)); }, [area, context.token]);
async function change(grant) { await perform(async () => { await api(path, 'POST', { email, action, reason, grant }); try { await load(); } catch { setState(null); } }, grant ? 'Concessão desta área registada.' : 'Revogação desta área registada.'); }
return <section className="panel"><h2>Acessos de {serviceAreas[area]}</h2><p>Âmbito: {context.company.name}{isDraft(area) ? ` / ${context.unit?.name}` : ' — cadastro da empresa, partilhado entre unidades'}. Apenas pode delegar direitos que possui neste âmbito.</p>{error && <p role="alert">{error}</p>}{state && <><form onSubmit={e => { e.preventDefault(); change(true); }}><label>Email do utilizador existente<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label><label>Acção<select value={action} onChange={e => setAction(e.target.value)}>{state.actions.map(a => <option key={a} value={a}>{actions[a]}</option>)}</select></label><label>Motivo da concessão ou revogação<textarea value={reason} onChange={e => setReason(e.target.value)} maxLength={500} required /></label><div className="actions"><button className="primary" disabled={busy || !state.actions.includes(action)}>Conceder nesta área</button><button type="button" disabled={busy || !email || !reason || !state.actions.includes(action)} onClick={() => change(false)}>Revogar nesta área</button></div></form><div className="table-scroll"><table><thead><tr><th>Utilizador</th><th>Concessão</th></tr></thead><tbody>{state.grants.map(grant => <tr key={grant.id}><td>{grant.name}<small>{grant.email}</small></td><td>{actions[grant.permission.split('.').at(-1)]}</td></tr>)}</tbody></table></div></>}</section>;
}
+7 -4
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import '../css/app.css';
import ServicesWorkspace, { serviceAreas } from './ServicesWorkspace.jsx';
const labels = { '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' };
@@ -10,11 +11,12 @@ function App() {
const [notice, setNotice] = useState('');
const [busy, setBusy] = useState(false);
const [page, setPage] = useState('home');
const [aSelection, setASelection] = useState(null);
const context = state?.context;
const can = (permission, unit = null) => !!context?.grants.some(g => g.permission === permission && g.unit_id === unit);
async function api(path, method = 'GET', body, token = context?.token) {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
async function api(path, method = 'GET', body, token = context?.token, commandKey = null) {
const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': state?.csrf || '', 'X-SGF-Context': token || '', ...(commandKey ? { 'Idempotency-Key': commandKey } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (response.status === 401) { await refresh(); }
@@ -44,16 +46,17 @@ function App() {
if (!state) return <main className="loading"><h1>SGF TEMPO</h1><p role="status">{error || 'A carregar…'}</p></main>;
if (state.setup_pending) return <main className="setup-pending"><p className="eyebrow">SGF TEMPO · PREPARAÇÃO LOCAL</p><h1>Falta concluir a primeira conta</h1><p>A aplicação está pronta, mas ainda não existe um utilizador. O login ficará disponível depois de definires a palavra-passe no procedimento local já autorizado.</p><section className="panel"><h2>Concluir no terminal do VS Code</h2><p>Executa este comando, confere o âmbito apresentado e define a palavra-passe nos dois pedidos de entrada oculta:</p><pre>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\laragon\data\sgf-tempo\run\complete-first-admin.ps1</pre><p className="help">Não envies a palavra-passe pelo chat. Nenhuma conta é criada por este ecrã.</p><button className="primary" disabled={busy} onClick={() => perform(async () => {}, '')}>Já concluí — actualizar estado</button>{error && <p role="alert">{error}</p>}</section></main>;
if (!state.user) return <div className="login-layout"><aside><span className="brand">TEMPO<span>SGF</span></span><h1>O seu trabalho,<br />no contexto certo.</h1><p>Uma entrada para as empresas e unidades a que tem acesso.</p><small>Ambiente local · Primeiro percurso funcional</small></aside><main className="login-card"><p className="eyebrow">BEM-VINDO AO SGF TEMPO</p><h2>Entrar na aplicação</h2><p className="muted">Use a conta preparada pelo procedimento local autorizado.</p>{error && <div className="alert error" role="alert">{error}</div>}<Login busy={busy} onSubmit={(email, password) => perform(() => api('/api/login', 'POST', { email, password }), 'Sessão iniciada. Seleccione o contexto de trabalho.')} /><p className="help">Precisa de recuperar o acesso? Solicite recuperação local assistida. Não existe recuperação por email nesta etapa.</p></main></div>;
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : [])].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
return <div className="shell"><aside className="sidebar"><a className="brand" href="/">TEMPO<span>SGF</span></a><p className="sidebar-label">ESPAÇO DE TRABALHO</p><nav>{[['home', 'Área inicial'], ['company', 'Empresa'], ['units', 'Unidades'], ...(can('access.manage') ? [['access', 'Acessos']] : []), ...Object.entries(serviceAreas).filter(([area]) => can(`${area}.view`, area.endsWith('_drafts') ? context?.unit?.id ?? null : null))].map(([id, text]) => <button key={id} className={page === id ? 'active' : ''} onClick={() => { setPage(id); setError(''); }}>{text}</button>)}</nav><div className="sidebar-bottom"><strong>{state.user.name}</strong><small>{state.user.email}</small><button disabled={busy} onClick={() => perform(() => api('/api/logout', 'POST'), 'Sessão terminada.')}>Sair da aplicação</button></div></aside><div className="workspace"><header><div><span className="eyebrow">CONTEXTO DE TRABALHO</span><strong>{context?.company.name || 'Seleccione uma empresa'}</strong><span className="muted">{context?.unit?.name || 'Sem unidade seleccionada'}</span></div><span className="local-badge">Ambiente local</span></header><main>
{error && <div className="alert error" role="alert">{error}</div>}{notice && <div className="alert success" role="status">{notice}</div>}
<section className="context-card"><label>Empresa<select aria-label="Empresa activa" disabled={busy} value={context?.company.id || ''} onChange={e => select(e.target.value)}><option value="">Escolher empresa autorizada</option>{state.companies.map(c => <option value={c.id} key={c.id}>{c.name}</option>)}</select></label><label>Unidade<select aria-label="Unidade activa" disabled={busy || !context} value={context?.unit?.id || ''} onChange={e => select(context.company.id, e.target.value ? Number(e.target.value) : null)}><option value="">Sem unidade seleccionada</option>{context?.units.map(u => <option value={u.id} key={u.id}>{u.name}</option>)}</select></label><p>Apenas os contextos com acesso explícito são apresentados.</p></section>
{!state.companies.length ? <section className="panel"><h1>Sem empresas autorizadas</h1><p>A conta existe, mas ainda não tem acesso a uma empresa. Peça ao responsável uma concessão explícita.</p></section> : !context ? <section className="panel"><h1>Escolha onde vai trabalhar</h1><p>Seleccione uma empresa. A pertença a um cliente contratante não dá acesso automático às suas empresas ou unidades.</p></section> : <div key={context.token}>
{page === 'home' && <><div className="page-title"><p className="eyebrow">ÁREA INICIAL</p><h1>Bem-vindo, {state.user.name.split(' ')[0]}.</h1><p className="muted">O contexto permanece visível e cada operação é validada pelo servidor.</p></div><div className="summary-grid"><section className="panel"><span className="eyebrow">EMPRESA ACTIVA</span><h2>{context.company.name}</h2><p>Cliente contratante #{context.company.tenant_id} · Empresa #{context.company.id}</p><button onClick={() => setPage('company')}>Consultar empresa</button></section><section className="panel"><span className="eyebrow">UNIDADE ACTIVA</span><h2>{context.unit?.name || 'Ainda não seleccionada'}</h2><p>{context.units.length} unidade(s) com consulta autorizada</p><button onClick={() => setPage('units')}>Ver unidades</button></section></div></>}
{page === 'company' && <section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Empresa</h1><NameForm key={context.company.id} label="Nome da empresa" initial={context.company.name} editable={can('company.edit')} busy={busy} onSave={name => perform(() => api(`/api/companies/${context.company.id}`, 'PATCH', { name }, context.token), 'Nome da empresa actualizado.')} />{!can('company.edit') && <p className="help">O seu acesso permite consulta. A edição exige uma concessão própria.</p>}</section>}
{page === 'units' && <><section className="panel"><p className="eyebrow">DADOS BÁSICOS</p><h1>Unidades autorizadas</h1>{!context.units.length && <p>Não tem consulta concedida a nenhuma unidade desta empresa.</p>}<ul className="unit-list">{context.units.map(u => <li key={u.id}><strong>{u.name}</strong><button disabled={busy || context.unit?.id === u.id} onClick={() => select(context.company.id, u.id)}>{context.unit?.id === u.id ? 'Seleccionada' : 'Seleccionar'}</button></li>)}</ul>{context.unit && <NameForm label="Nome da unidade" initial={context.unit.name} editable={can('unit.edit', context.unit.id)} busy={busy} onSave={name => perform(() => api(`/api/units/${context.unit.id}`, 'PATCH', { name }, context.token), 'Nome da unidade actualizado.')} />}</section>{can('unit.create') && <section className="panel"><h2>Criar unidade</h2><p className="help">A unidade será criada nesta empresa. Receberá consulta e edição explícitas apenas para a nova unidade.</p><NameForm label="Nome da nova unidade" initial="" editable busy={busy} button="Criar unidade" onSave={name => perform(() => api('/api/units', 'POST', { name }, context.token), 'Unidade criada com os seus acessos explícitos.')} /></section>}</>}
{serviceAreas[page] && <ServicesWorkspace key={page} area={page} context={context} can={can} api={api} perform={perform} busy={busy} initialId={aSelection?.area === page ? aSelection.id : null} navigate={(area,id) => { setASelection({area,id}); setPage(area); }} />}
{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 · Primeiro percurso funcional · Sem módulos financeiros nesta etapa</footer></div></div>;
</main><footer>SGF TEMPO · Entrega A: preparação em rascunho · Sem emissão fiscal ou lançamentos</footer></div></div>;
}
function Login({ busy, onSubmit }) {
const [email, setEmail] = useState(''); const [password, setPassword] = useState('');