70 lines
13 KiB
React
70 lines
13 KiB
React
import React, { useEffect, useState } from 'react';
|
|
|
|
const notice = 'RASCUNHO — SEM VALIDADE FISCAL';
|
|
const missing = historical => historical ? 'Não capturado nesta confirmação' : 'Por completar';
|
|
const present = (value, historical) => value === null || value === undefined || value === '' ? missing(historical) : value;
|
|
const modes = { undefined: 'Por definir', included: 'Imposto incluído — indicação proposta', added: 'Imposto acrescido — indicação proposta' };
|
|
|
|
export default function DocumentPreview({ id, historical, context, api, onClose }) {
|
|
const [view, setView] = useState(null); const [error, setError] = useState(''); const [stale, setStale] = useState(false); const [loading, setLoading] = useState(true);
|
|
const path = `/api/document-preview/${historical ? 'history' : 'draft'}/${id}`;
|
|
async function update() {
|
|
setLoading(true); setError('');
|
|
try { const result = await api(path); setView(result); setStale(false); }
|
|
catch (e) { setView(null); setError(e.message); }
|
|
finally { setLoading(false); }
|
|
}
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
setView(null); setLoading(true);
|
|
api(path).then(result => { if (!disposed) { setView(result); setLoading(false); } }).catch(e => { if (!disposed) { setError(e.message); setLoading(false); } });
|
|
return () => { disposed = true; };
|
|
}, [id, historical, context.token]);
|
|
useEffect(() => {
|
|
if (!view) return;
|
|
let disposed = false; let checking = false;
|
|
async function check() {
|
|
if (document.hidden || checking) return; checking = true;
|
|
try { const result = await api(`${path}/revision`); if (!disposed && result.revision !== view.revision) setStale(true); }
|
|
catch (e) { if (!disposed) { setView(null); setError(e.message); } }
|
|
finally { checking = false; }
|
|
}
|
|
const timer = setInterval(check, 10000); window.addEventListener('focus',check); document.addEventListener('visibilitychange',check);
|
|
return () => { disposed = true; clearInterval(timer); window.removeEventListener('focus',check); document.removeEventListener('visibilitychange',check); };
|
|
}, [view?.revision, context.token]);
|
|
|
|
return <div className="preview-shell"><div className="preview-mandatory" role="note">{notice}</div><div className="preview-controls"><button onClick={onClose}>Voltar ao registo</button><button disabled={loading} onClick={update}>Actualizar pré-visualização</button><p className="help">Consulta sem emissão ou alteração de dados. Verificação de actualidade a cada 10 segundos e ao regressar à janela.</p></div>
|
|
{error && <div className="alert error" role="alert">{error}</div>}{loading && <p role="status">A preparar a consulta…</p>}
|
|
{stale && <div className="preview-stale" role="alert"><strong>Pré-visualização desactualizada.</strong> Os dados abaixo mantêm a versão consultada. Use Actualizar pré-visualização para carregar explicitamente a nova versão.</div>}
|
|
{view && <article className="preview-sheet" aria-label={historical ? 'Confirmação interna histórica' : 'Rascunho de factura de serviço'}><div className="preview-document-heading"><div>{view.logo && <img src={view.logo} alt="Logótipo local do emitente" className="preview-logo" />}<p className="eyebrow">{historical ? 'CONSULTA DA FOTOGRAFIA PRESERVADA' : 'PRÉ-VISUALIZAÇÃO DOCUMENTAL'}</p><h1>{view.title}</h1></div><p>{notice}</p></div>
|
|
<p><strong>{view.reference}</strong> · Versão do rascunho {view.draft_version}{historical ? ` · Fotografia esquema ${view.snapshot_schema}` : ` · Preparação ${view.preparation_version} · Apresentação ${view.model_version}`}</p>
|
|
<p className="help">Instante desta consulta: {view.consulted_at}. Não é data de emissão.</p>
|
|
{historical && <p className="preview-historical">Confirmação interna histórica de {view.confirmed_at}, autor #{view.actor_id}. Conteúdo exclusivamente da fotografia; campos ausentes não foram reconstruídos com dados actuais.</p>}
|
|
<div className="preview-parties">{[['Emitente',view.issuer],['Cliente',view.client]].map(([label,party]) => <section key={label}><h2>{label}</h2><strong>{present(party.name,historical)}</strong><p>NUIT: {present(party.data.nuit,historical)}</p><p>{present(party.data.address,historical)}<br />{present(party.data.locality,historical)} · {present(party.data.country,historical)}</p></section>)}</div>
|
|
{view.contacts && <section><h2>Contactos do emitente</h2><p className="preview-text">{view.contacts}</p></section>}
|
|
<section><h2>Serviço e condições propostas</h2><div className="preview-dates"><p>Data do serviço: <strong>{present(view.prepared.service_date,historical)}</strong></p><p>Período: <strong>{present(view.prepared.period_start,historical)} — {present(view.prepared.period_end,historical)}</strong></p><p>Vencimento proposto: <strong>{present(view.prepared.proposed_due_date,historical)}</strong></p></div><p className="preview-text">Condições: {present(view.prepared.payment_terms,historical)}</p><p>Moeda: <strong>{view.data.currency || 'Moeda por definir'}</strong> · {modes[view.prepared.price_mode || 'undefined']}</p><p className="help">Origem: prestação #{view.source_id}, versão {view.data.source_version ?? 'não capturada'}. Os dados não confirmam execução do serviço.</p></section>
|
|
<section><h2>Linhas do rascunho</h2><div className="preview-lines">{view.data.lines.map((line,i) => <div className="preview-line" key={i}><div><strong>{i + 1}. {line.service_name}</strong>{line.description && <p className="preview-text">{line.description}</p>}</div><p>Quantidade<br /><strong>{present(line.quantity,historical)}</strong></p><p>Unidade<br /><strong>{present(view.prepared.units?.[i],historical)}</strong></p><p>Preço proposto<br /><strong>{present(line.unit_price,historical)}</strong></p></div>)}</div></section>
|
|
<section className="preview-values"><p>Valor aritmético provisório <strong>{view.base_amount ?? 'Por completar'} {view.data.currency || '(moeda por definir)'}</strong></p><p>Imposto por apurar <strong>Não apurado</strong></p><p>Total definitivo por apurar <strong>Não apurado</strong></p><p className="help">Valores exactos propostos, sem cálculo fiscal, novas taxas ou arredondamentos. Não representam base tributável ou montante monetário definitivo. Um preço com imposto incluído desconhecido não foi decomposto.</p></section>
|
|
{!historical && view.calculation && <section aria-label="Cálculo de serviço"><h2>Cálculo de serviço</h2>{view.calculation.pending.map((text,i)=><p key={i}>{text}</p>)}
|
|
{view.calculation.environment === 'synthetic_test_only' && <><strong>ENSAIO SINTÉTICO — APENAS TESTES</strong><p>{view.calculation.policy} · Truncagem operacional proposta; não é obrigação legal comprovada.</p>
|
|
{view.calculation.can_calculate && view.calculation.profiles.map(profile=><button key={profile.id} disabled={loading || stale} onClick={async()=>{setLoading(true);try { await api(`/api/service-calculations/${id}`,'POST',{draft_version:view.calculation.draft_version,preparation_version:view.calculation.preparation_version,profile_id:profile.id,profile_version:profile.version}); await update(); } catch(e) {setError(e.message);setLoading(false);} }}>Calcular ensaio · {profile.identifier} v{profile.version}</button>)}
|
|
{view.calculation.history.map(entry=><div key={entry.id} className="preview-values"><h3>Resultado de ensaio #{entry.id} — {entry.current ? 'Actual para estas versões' : 'Desactualizado — apenas histórico'}</h3><p>Rascunho v{entry.snapshot.input.draft_version} · Preparação v{entry.snapshot.input.preparation_version} · Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version}</p>
|
|
<p>Base aritmética exacta: <strong>{entry.snapshot.result.base_exact}</strong></p><p>IVA exacto: <strong>{entry.snapshot.result.vat_exact}</strong></p><p>IVA truncado proposto: <strong>{entry.snapshot.result.vat_document_proposed}</strong></p><p>Diferença descartada: <strong>{entry.snapshot.result.discarded_difference}</strong></p><p>Total documental proposto: <strong>{entry.snapshot.result.total_document_proposed ?? 'Pendente: representação da base/total'}</strong> {entry.snapshot.result.currency}</p>{entry.snapshot.result.pending.map((text,i)=><p key={i}>{text}</p>)}<p>Sem validação fiscal. {notice}</p></div>)}
|
|
</>}
|
|
</section>}
|
|
{!historical && view.fiscal_history?.length>0 && <section><h2>Cálculo proposto do serviço — condicionado</h2>{view.fiscal_history.map(entry=><div className="preview-values" key={entry.id}><h3>Resultado #{entry.id} · {entry.current?'Actual para as versões validadas':'Desactualizado — histórico'}</h3><p>Perfil {entry.snapshot.input.profile_identifier} v{entry.snapshot.input.profile_version} · decisão temporal v{entry.snapshot.input.temporal_version}</p><p>Base exacta: {entry.snapshot.result.base_exact} {entry.snapshot.result.currency}</p><p>IVA exacto: {entry.snapshot.result.vat_exact}</p><p>IVA truncado proposto: {entry.snapshot.result.vat_document_proposed}</p><p>Diferença preservada: {entry.snapshot.result.discarded_difference}</p><p>Total proposto: {entry.snapshot.result.total_document_proposed ?? 'Pendente'}</p><p>Exigibilidade: {entry.snapshot.result.exigibility_date ?? 'Não determinada'}</p><p>Período de apuramento: {entry.snapshot.result.assessment_period ?? 'Não determinado'}</p>{entry.snapshot.result.pending.map((p,i)=><p key={i}>{p}</p>)}<p>{notice} · Não representa emissão ou apuramento líquido do imposto.</p></div>)}</section>}
|
|
{!!view.payments.length && <section><h2>Instruções do emitente para facilitar o pagamento</h2><p className="help">Informação introduzida pelo utilizador, sem verificação bancária. Não regista recebimento nem inicia operação bancária.</p>{view.payments.map(payment => <div key={payment.id} className="preview-payment"><h3>{payment.data.label} · versão {payment.version}</h3>{Object.entries({ bank:'Banco', holder:'Titular', account_number:'Conta', nib:'NIB', iban:'IBAN', swift:'SWIFT', bank_code:'Código bancário', currency:'Moeda' }).filter(([key]) => payment.data[key]).map(([key,label]) => <p key={key}>{label}: <strong>{payment.data[key]}</strong></p>)}{payment.data.instructions && <p className="preview-text">{payment.data.instructions}</p>}</div>)}</section>}
|
|
{view.observations && <section><h2>Observações</h2><p className="preview-text">{view.observations}</p></section>}
|
|
<section className="preview-pending" aria-label="Pendências obrigatórias"><h2>Dados em falta e validações pendentes</h2><p>Preenchido não significa validado. Imposto e total definitivo continuam por apurar.</p><ul>{Object.entries(view.pending).map(([code,text]) => <li key={code}>{text}</li>)}</ul></section><p className="preview-end-notice">{notice}</p>
|
|
</article>}
|
|
</div>;
|
|
}
|
|
|
|
export function DocumentPreviewConfiguration({ api, perform, can, busy, context }) {
|
|
const [model,setModel] = useState(null); const [form,setForm] = useState(null); const [error,setError] = useState('');
|
|
async function load() { const result = await api('/api/document-preview/model'); setModel(result); setForm(result.data); }
|
|
useEffect(() => { load().catch(e => setError(e.message)); }, [context.token]);
|
|
const editable = can('document_preview.configure');
|
|
return <section className="panel"><p className="eyebrow">APRESENTAÇÃO POR EMPRESA</p><h1>Apresentação do rascunho</h1><p>Configuração delimitada; o aviso de rascunho e todas as pendências são obrigatórios.</p>{error && <p role="alert">{error}</p>}{model && <form onSubmit={e => { e.preventDefault(); perform(async () => { await api('/api/document-preview/model','PATCH',{ version:model.version,...form }); await load(); }, 'Apresentação guardada; não altera documentos ou fotografias B1.'); }}><p>Versão {model.version} · {model.local_logo_available ? 'Logótipo local disponível' : 'Logótipo não disponível: falta recurso local autorizado'}</p>{Object.entries({show_logo:'Apresentar logótipo local da empresa',show_contacts:'Apresentar contactos',show_observations:'Apresentar observações',show_payment_instructions:'Apresentar instruções de pagamento seleccionadas no rascunho'}).map(([field,label]) => <label className="doc-instruction-option" key={field}><input type="checkbox" disabled={!editable} checked={form[field]} onChange={e => setForm({...form,[field]:e.target.checked})} />{label}</label>)}<label>Contactos de apresentação<textarea readOnly={!editable} value={form.contact_text} maxLength={2000} onChange={e => setForm({...form,contact_text:e.target.value})} /></label><label>Observações de apresentação<textarea readOnly={!editable} value={form.observations} maxLength={2000} onChange={e => setForm({...form,observations:e.target.value})} /></label><p className="help">Apenas texto, sem HTML, recursos externos ou segredos. A escolha de instruções é reutilizada da preparação documental; não é uma pergunta sobre como o cliente pagará.</p>{editable ? <button className="primary" disabled={busy}>Guardar apresentação</button> : <p>Apenas consulta; não tem configuração concedida.</p>}</form>}</section>;
|
|
}
|