This commit is contained in:
2026-10-29 22:42:37 +02:00
parent 78e89a51d2
commit 53ff46b0a2
52 changed files with 2082 additions and 79 deletions
+6 -3
View File
@@ -1,12 +1,13 @@
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, navigate }) {
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([]);
@@ -18,11 +19,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
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); }, [area, context.token]);
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);
@@ -71,11 +73,12 @@ export default function ServicesWorkspace({ area, context, can, api, perform, bu
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>{!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}{record.internal_confirmation ? ' · ' + record.internal_confirmation.label : isDraft(area) ? ' · Rascunho' : ''}</small></button>)}</section><section className="panel a-detail">
<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) && <>