122 lines
19 KiB
React
122 lines
19 KiB
React
import React, { useEffect, useState } from 'react';
|
|
import DocumentPreview from './DocumentPreview.jsx';
|
|
import {visibleBillingSections} from './billing-menu.js';
|
|
|
|
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, initialCreate=false, 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 [reviewConfirmation, setReviewConfirmation] = useState(false);
|
|
const [previewMode, setPreviewMode] = useState(null);
|
|
const [search, setSearch] = useState('');
|
|
const path = `/api/a/${area}`;
|
|
|
|
async function load() { const result = await api(path); setRecords(result.records); }
|
|
async function open(id) { setError(''); setReviewConfirmation(false); try { setSelected(await api(`${path}/${id}`)); setForm(null); } catch (e) { setError(e.message); } }
|
|
useEffect(() => { load().catch(e => setError(e.message)); if (initialId) open(initialId); else if(initialCreate&&allowed('create')) start(); }, [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>;
|
|
if (previewMode && selected) return <DocumentPreview id={selected.id} historical={previewMode === 'history'} context={context} api={api} onClose={() => setPreviewMode(null)} />;
|
|
return <>
|
|
{area==='billing_drafts'&&<nav className="billing-section-tabs" aria-label="Submenus de facturação">{visibleBillingSections(can,unit).map(s=><button key={s.id} aria-current={s.id===area?'page':undefined} onClick={()=>{if(s.id!==area)navigate(s.id);}}>{s.label}</button>)}</nav>}
|
|
<div className="a-heading"><div><p className="eyebrow">SERVIÇOS · PREPARAÇÃO E CONFIRMAÇÃO INTERNA</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">{area === 'billing_drafts' ? 'Rascunhos e confirmação interna — sem validade fiscal. ' : '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>{area==='billing_drafts'&&<label>Pesquisar referência, designação ou cliente<input type="search" value={search} onChange={e=>setSearch(e.target.value)}/></label>}{!records.length && <p className="muted">Ainda não há registos neste âmbito.</p>}{records.filter(record=>area!=='billing_drafts'||[record.name,record.id,record.data?.client_name].some(v=>String(v??'').toLocaleLowerCase('pt').includes(search.trim().toLocaleLowerCase('pt')))).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}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : 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') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => start(selected)}>Editar registo</button>}</div><p className="help">Identificador interno #{selected.id} · Versão {selected.version}{isDraft(area) ? ' · Origem em rascunho' : ''}</p>
|
|
{selected.workshop_commercial_state && <p className="alert home-notice">{selected.workshop_commercial_state === "superseded" ? "HISTÓRICO — preparação comercial substituída. Sem nova confirmação." : selected.workshop_commercial_state === "cancelled" ? "PREPARAÇÃO COMERCIAL CANCELADA — apenas consulta histórica." : "Origem de oficina protegida. Correcção apenas pelo circuito comercial próprio."}</p>}{selected.data.description && <p>{selected.data.description}</p>}
|
|
{isDraft(area) && <><p><strong>Cliente:</strong> {selected.data.client_name}</p>{selected.source_id && <p>Origem: prestação #{selected.source_id}, versão {selected.data.source_version}</p>}<p><strong>Moeda:</strong> {selected.data.currency || 'Não indicada — pendente'}</p><div className="table-scroll"><table><thead><tr><th>Serviço</th><th>Quantidade</th><th>Preço proposto</th></tr></thead><tbody>{selected.data.lines.map((line, i) => <tr key={i}><td>{line.service_name}</td><td>{line.quantity ?? 'Por preencher'}</td><td>{line.unit_price ?? 'Por preencher'}</td></tr>)}</tbody></table></div><p><strong>Base aritmética:</strong> {selected.base_amount ?? 'Incompleta'} {selected.data.currency || '(moeda pendente)'}</p><p className="help">Esta base não é um total fiscal ou valor definitivo a pagar. Imposto por validar; contabilidade não processada.</p>
|
|
{Object.keys(selected.pending).length > 0 && <div className="a-pending"><h3>Pendências</h3><ul>{Object.entries(selected.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul></div>}
|
|
{area === 'service_drafts' && !selected.workshop_commercial_state && can('billing_drafts.create', context.unit?.id) && <button className="primary" disabled={busy} onClick={prepareBilling}>Preparar rascunho de facturação</button>}
|
|
{area === 'billing_drafts' && allowed('edit') && !selected.confirmation && !selected.workshop_commercial_state && <button disabled={busy} onClick={() => perform(async () => { await api(`${path}/${selected.id}`, 'PATCH', { version: selected.version, reload_source: true }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Dados da prestação reutilizados explicitamente numa nova versão do rascunho.')}>Actualizar explicitamente da prestação</button>}
|
|
{area === 'billing_drafts' && !selected.confirmation && (!selected.workshop_commercial_state || selected.workshop_commercial_state === 'current') && can('billing_drafts.confirm_internal', context.unit?.id) && <div className="a-pending"><h3>Confirmação interna</h3><p>Fixa esta versão e os dados que a sustentam. Bloqueia a edição deste rascunho. Não confirma a execução do serviço, não emite factura nem reconhece receita.</p>{reviewConfirmation ? <><p>Confirmar a versão {selected.version} neste contexto: {context.company.name} / {context.unit?.name}? Quantidades e preços devem estar completos; moeda e regras desconhecidas ficam pendentes na fotografia.</p><div className="actions"><button className="primary" disabled={busy} onClick={() => perform(async () => { await api(`/api/b1/billing-drafts/${selected.id}/confirm-internal`, 'POST', { version: selected.version }, context.token, key); await open(selected.id); await load(); setKey(crypto.randomUUID()); }, 'Confirmado internamente — sem validade fiscal')}>Confirmar internamente esta versão</button><button onClick={() => setReviewConfirmation(false)}>Voltar ao rascunho</button></div></> : <button disabled={busy} onClick={() => { setKey(crypto.randomUUID()); setReviewConfirmation(true); }}>Rever confirmação interna</button>}</div>}
|
|
{selected.confirmation && <InternalDocument confirmation={selected.confirmation} />}
|
|
{area === 'billing_drafts' && (selected.confirmation ? <button onClick={() => setPreviewMode('history')}>Abrir confirmação interna histórica</button> : can('issuer_documents.view') && can('client_documents.view') && can('draft_documents.view',context.unit?.id) && <button className="primary" onClick={() => setPreviewMode('draft')}>Pré-visualizar rascunho</button>)}
|
|
</>}
|
|
<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 InternalDocument({ confirmation }) {
|
|
const snapshot = confirmation.snapshot;
|
|
return <section className="a-pending"><h3>{confirmation.label}</h3><p><strong>Referência interna:</strong> {confirmation.internal_reference} (não é numeração fiscal)</p><p>Autor #{snapshot.actor_id} · {snapshot.confirmed_at}</p><p><strong>Cliente preservado:</strong> {snapshot.client.name} · versão {snapshot.client.version}</p><p><strong>Origem preservada:</strong> prestação #{snapshot.service_draft.id}, versão {snapshot.service_draft.version}; rascunho #{snapshot.billing_draft.id}, versão {snapshot.billing_draft.version}</p><p><strong>Moeda preservada:</strong> {snapshot.data.currency || 'Desconhecida — pendente'}</p><ul>{snapshot.data.lines.map((line, i) => <li key={i}>{line.service_name} · quantidade {line.quantity} · preço {line.unit_price}</li>)}</ul><p>Base aritmética exacta: {snapshot.base_amount}. Não é total monetário definitivo ou fiscal.</p><p>Evento #{confirmation.event?.id} · Capturado · Contabilização bloqueada por pendências. Nenhum lançamento produzido.</p><h4>Pendências preservadas</h4><ul>{Object.entries(snapshot.pending).map(([code, message]) => <li key={code}>{message}</li>)}</ul><p>Fotografia imutável. Não há edição, eliminação, reabertura, correcção ou anulação neste incremento.</p><details><summary>Fotografia histórica completa</summary><pre>{JSON.stringify(snapshot, null, 2)}</pre></details></section>;
|
|
}
|
|
|
|
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>;
|
|
}
|