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

24 lines
13 KiB
React
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React,{useEffect,useMemo,useState}from'react';import{WorkshopStockOperation}from'./workshop-stock-operation.js';
const qs=x=>new URLSearchParams(Object.fromEntries(Object.entries(x).filter(([,v])=>v!==''&&v!==null))).toString();
function Options({api,view,label,value,onChange,disabled,context}){const[q,setQ]=useState({search:'',page:1,per_page:25}),[search,setSearch]=useState(''),[p,setP]=useState(null);useEffect(()=>{let active=true;api('/api/commercial-quotes/options?'+qs({view,...q})).then(v=>{if(active)setP(v.records);}).catch(()=>{if(active)setP(null);});return()=>{active=false;};},[context.token,view,q]);return <fieldset disabled={disabled}><label>Pesquisar {label}<input value={search} onChange={e=>setSearch(e.target.value)}/></label><button type="button" onClick={()=>{onChange('');setQ({...q,search,page:1});}}>Pesquisar</button><label>{label}<select required value={value} onChange={e=>onChange(e.target.value,p?.data.find(r=>r.id===Number(e.target.value)))}><option value="">Escolher…</option>{p?.data.map(r=><option key={r.id} value={r.id}>{r.name}</option>)}</select></label>{p&&<><button type="button" disabled={p.current_page<=1} onClick={()=>{onChange('');setQ({...q,page:q.page-1});}}>Anterior</button><button type="button" disabled={p.current_page>=p.last_page} onClick={()=>{onChange('');setQ({...q,page:q.page+1});}}>Seguinte</button></>}</fieldset>;}
const labels={draft:'Preparado — por rever',approved:'Revisto — aguarda cliente',rejected:'Revisão recusada',customer_accepted:'Aceite pelo cliente',customer_declined:'Recusado pelo cliente',converted:'Convertido em rascunho'};
export default function CommercialQuotesWorkspace({api,context,can,navigate,actorId}){
const[query,setQuery]=useState({page:1,per_page:25,state:'',search:''}),[search,setSearch]=useState(''),[data,setData]=useState(null),[form,setForm]=useState(null),[origin,setOrigin]=useState(null),[preview,setPreview]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''),[busy,setBusy]=useState(false),[history,setHistory]=useState(null),[historyKey,setHistoryKey]=useState(null);const command=useMemo(()=>new WorkshopStockOperation(api),[context.token]),has=right=>can('commercial_quotes.'+right,context.unit?.id),locked=busy||command.uncertain;
const load=async()=>setData((await api('/api/commercial-quotes?'+qs(query))).quotes);useEffect(()=>{let active=true;api('/api/commercial-quotes?'+qs(query)).then(v=>{if(active)setData(v.quotes);}).catch(e=>{if(active)setError(e.message);});return()=>{active=false;};},[query,context.token]);
function start(row=null,action='prepare'){setError('');setPreview(null);setOrigin(row);const content=row?.data;setForm({action,quote_key:row?.quote_key??crypto.randomUUID(),version:row?.version??0,reason:'',data:action==='prepare'?{client_id:content?.client_id??'',currency:'MZN',valid_until:content?.valid_until??'',source_order_id:content?.source_order_id??null,description:content?.description??'',lines:content?.lines?.map(l=>({service_id:l.service_id,quantity:l.quantity,unit_price:l.unit_price,description:l.description??''}))??[{service_id:'',quantity:'',unit_price:'',description:''}]}:action==='review'?{decision:''}:action==='customer'?{decision:'',representative:'',capacity:'',evidence:'',decided_on:''}:{}});}
const update=(key,value)=>setForm(f=>({...f,data:{...f.data,[key]:value}}));const line=(i,key,value)=>setForm(f=>({...f,data:{...f.data,lines:f.data.lines.map((l,n)=>n===i?{...l,[key]:value}:l)}}));
async function prepare(e){e.preventDefault();setBusy(true);setError('');try{const input=JSON.parse(JSON.stringify(form));if(input.action==='prepare'){input.data.client_id=Number(input.data.client_id);input.data.source_order_id=input.data.source_order_id?Number(input.data.source_order_id):null;input.data.lines=input.data.lines.map(l=>({...l,service_id:Number(l.service_id)}));}setPreview(await api('/api/commercial-quotes/preview','POST',input,undefined,crypto.randomUUID()));}catch(e){setError(e.message);}finally{setBusy(false);}}
async function confirm(retry=false){setBusy(true);setError('');try{const r=retry?await command.retry():await command.send('/api/commercial-quotes/confirm',{preview:preview.preview},crypto.randomUUID());setNotice(`Orçamento v${r.version}: ${labels[r.state]}${r.replayed?' · recibo recuperado':''}.`);setForm(null);setPreview(null);await load();}catch(e){setError(e.message);if(!command.uncertain)setPreview(null);}finally{setBusy(false);}}
async function viewHistory(key,page=1){try{const result=await api('/api/commercial-quotes/'+key+'/history?page='+page);setHistory(result.events);setHistoryKey(key);}catch(e){setError(e.message);}}
return <section className="panel" data-sgf-operation-uncertain={preview||command.uncertain?'true':undefined}><h1>Orçamentos comerciais e de reparação</h1><p>Proposta própria, revisão distinta e decisão documentada do cliente. Os valores são base antes de impostos: a conferência fiscal ocorre no seu percurso. Aceitar não emite factura, autoriza trabalho ou cria dívida/stock.</p>{error&&<p role="alert">{error}</p>}{notice&&<p role="status">{notice}</p>}<form onSubmit={e=>{e.preventDefault();setQuery({...query,search,page:1});}}><fieldset disabled={locked}><label>Cliente<input value={search} onChange={e=>setSearch(e.target.value)}/></label><label>Estado<select value={query.state} onChange={e=>setQuery({...query,state:e.target.value,page:1})}><option value="">Todos</option>{Object.entries(labels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></label><label>Por página<select value={query.per_page} onChange={e=>setQuery({...query,per_page:Number(e.target.value),page:1})}><option value="25">25</option><option value="100">100</option></select></label><button>Pesquisar</button></fieldset></form>{has('prepare')&&<button disabled={locked||Boolean(form)} onClick={()=>start()}>Novo orçamento</button>}
{data&&<><div className="table-scroll"><table><thead><tr><th>Referência/cliente</th><th>Base proposta</th><th>Validade/estado</th><th>Acções</th></tr></thead><tbody>{data.data.map(row=><tr key={row.id}><td>ORC-QA-{row.quote_key.slice(0,8).toUpperCase()}<small>{row.client_name} · v{row.version}</small></td><td>{row.data.base} MZN</td><td>{row.data.valid_until}<small>{row.expired?'Caducado · ':''}{labels[row.state]}</small></td><td><a href={'/commercial-quotes/'+row.quote_key+'/print?version='+row.version} target="_blank" rel="noopener noreferrer">Versão para impressão</a><button disabled={locked} onClick={()=>viewHistory(row.quote_key)}>Histórico</button>{!row.conversion&&has('prepare')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&<button disabled={locked||Boolean(form)} onClick={()=>start(row)}>Rever proposta</button>}{row.state==='draft'&&has('review')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&row.prepared_by!==actorId&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'review')}>Conferir comercialmente</button>}{row.state==='approved'&&has('prepare')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&!row.expired&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'customer')}>Registar decisão do cliente</button>}{row.state==='customer_accepted'&&has('convert')&&(!row.source_order_id||can('workshop_billing.view',context.unit?.id))&&!row.expired&&<button disabled={locked||Boolean(form)} onClick={()=>start(row,'convert')}>Converter explicitamente</button>}{row.conversion&&navigate&&<button disabled={locked||Boolean(preview)} onClick={()=>navigate('billing_drafts',{area:'billing_drafts',id:row.conversion.billing_draft_id})}>Abrir o mesmo rascunho</button>}</td></tr>)}</tbody></table></div>{!data.data.length&&<p>Sem orçamentos visíveis para estes filtros.</p>}<button disabled={locked||data.current_page<=1} onClick={()=>setQuery({...query,page:query.page-1})}>Anterior</button><span> Página {data.current_page}/{data.last_page} </span><button disabled={locked||data.current_page>=data.last_page} onClick={()=>setQuery({...query,page:query.page+1})}>Seguinte</button></>}
{form&&<section className="panel"><h2>{form.action==='prepare'?'Preparar proposta':form.action==='review'?'Revisão distinta':form.action==='customer'?'Decisão do cliente':'Conversão explícita'}</h2><form onSubmit={prepare}><fieldset disabled={locked||Boolean(preview)}>
{form.action==='prepare'?<><Options api={api} context={context} view="clients" label="Cliente" value={form.data.client_id} onChange={value=>update('client_id',value)}/><label>Validade até<input type="date" required value={form.data.valid_until} onChange={e=>update('valid_until',e.target.value)}/></label><label>Descrição<textarea required value={form.data.description} onChange={e=>update('description',e.target.value)}/></label>{can('workshop_billing.view',context.unit?.id)&&<><label>Origem<select value={form.data.source_order_id===null?'commercial':'workshop'} onChange={e=>update('source_order_id',e.target.value==='workshop'?'':null)}><option value="commercial">Proposta comercial</option><option value="workshop">Ordem de reparação</option></select></label>{form.data.source_order_id!==null&&<Options api={api} context={context} view="orders" label="Ordem de origem" value={form.data.source_order_id} onChange={(value,row)=>setForm(f=>({...f,data:{...f.data,source_order_id:value,client_id:row?.client_id??f.data.client_id}}))}/>}</>}{form.data.lines.map((l,i)=><section key={i}><h3>Linha {i+1}</h3><Options api={api} context={context} view="services" label="Serviço" value={l.service_id} onChange={value=>line(i,'service_id',value)}/><label>Quantidade<input required value={l.quantity} onChange={e=>line(i,'quantity',e.target.value)}/></label><label>Preço unitário proposto<input required value={l.unit_price} onChange={e=>line(i,'unit_price',e.target.value)}/></label><label>Descrição<input value={l.description??''} onChange={e=>line(i,'description',e.target.value)}/></label>{form.data.lines.length>1&&<button type="button" onClick={()=>update('lines',form.data.lines.filter((_,n)=>n!==i))}>Remover linha</button>}</section>)}<button type="button" disabled={form.data.lines.length>=100} onClick={()=>update('lines',[...form.data.lines,{service_id:'',quantity:'',unit_price:'',description:''}])}>Adicionar linha</button></>:<p>{origin?.client_name} · base {origin?.data.base} MZN · versão {form.version}.</p>}
{form.action==='review'&&<label>Decisão<select required value={form.data.decision} onChange={e=>update('decision',e.target.value)}><option value="">Escolher…</option><option value="approved">Aprovar proposta</option><option value="rejected">Recusar proposta</option></select></label>}
{form.action==='customer'&&<><label>Decisão do cliente<select required value={form.data.decision} onChange={e=>update('decision',e.target.value)}><option value="">Escolher…</option><option value="accepted">Aceite</option><option value="declined">Recusada</option></select></label>{[['representative','Representante'],['capacity','Qualidade/representação'],['evidence','Referência/evidência da decisão'],['decided_on','Data da decisão']].map(([k,label])=><label key={k}>{label}<input type={k==='decided_on'?'date':'text'} required value={form.data[k]} onChange={e=>update(k,e.target.value)}/></label>)}</>}
{form.action==='convert'&&<p>Cria uma prestação e um rascunho ligados ao orçamento aceite, conservando preços e origem. Não emite factura, altera impostos ou lança dívida.</p>}
<label>Fundamento<textarea required minLength={10} maxLength={2000} value={form.reason} onChange={e=>setForm({...form,reason:e.target.value})}/></label><button>Conferir impacto</button></fieldset></form>{preview&&<div role="status"><h3>Impacto antes da decisão</h3><p>{preview.impact.reference} · {preview.impact.client} · base {preview.impact.base} MZN · {labels[preview.impact.state]}.</p>{preview.impact.lines.map((l,i)=><p key={i}>{l.service_name}: {l.quantity} × {l.unit_price} MZN</p>)}<p>Sem emissão, dívida ou Stock automáticos. Validade {preview.impact.valid_until}.</p><button disabled={locked} onClick={()=>confirm()}>Confirmar esta decisão</button></div>}{command.uncertain&&<button disabled={busy} onClick={()=>confirm(true)}>Recuperar a mesma confirmação</button>}<button disabled={locked} onClick={()=>{setForm(null);setPreview(null);}}>Descartar</button></section>}
{history&&<section className="panel"><h2>Histórico do orçamento</h2>{history.data.map(r=><article key={r.id}><strong>v{r.version} · {labels[r.state]}</strong><p>Autor #{r.actor_id} · {r.created_at} · {r.reason}</p></article>)}<button disabled={locked||history.current_page<=1} onClick={()=>viewHistory(historyKey,history.current_page-1)}>Anterior</button><button disabled={locked||history.current_page>=history.last_page} onClick={()=>viewHistory(historyKey,history.current_page+1)}>Seguinte</button></section>}
</section>;
}