24 lines
13 KiB
React
24 lines
13 KiB
React
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>;
|
||
}
|