Files
SGF/resources/js/ReceivablePositionsWorkspace.jsx
2026-11-11 09:00:17 +02:00

13 lines
5.7 KiB
React

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>;
}