46 lines
10 KiB
React
46 lines
10 KiB
React
import React,{useEffect,useRef,useState} from 'react';
|
|
import {milliQuantity} from './StockReadWorkspace.jsx';
|
|
import {WorkshopStockOperation} from './workshop-stock-operation.js';
|
|
|
|
export function WorkshopStockImpact({impact:p}) {
|
|
return <p>Físico: {milliQuantity(p.physical_before)} → {milliQuantity(p.physical_after)}. Nova saída: {milliQuantity(p.quantity_milli)}. {['link_existing_delivery','allocate_existing_delivery'].includes(p.mode)&&<>Ligar entrega #{p.existing_movement_id}: {milliQuantity(p.linked_quantity_milli)}, sem novo abatimento. </>}{p.mode==='return_existing_delivery'&&<>Entrada por devolução: {milliQuantity(p.return_quantity_milli)}, da entrega #{p.existing_movement_id}. </>}{['allocate_existing_delivery','return_existing_delivery'].includes(p.mode)&&<>Quantidade não associada: {milliQuantity(p.unallocated_before)} → {milliQuantity(p.unallocated_after)}. </>}Artigo #{p.stock_item_id}, armazém #{p.warehouse_id}, origem versão {p.source_version}.</p>;
|
|
}
|
|
export function WorkshopStockClosureImpact({action}) {
|
|
return <p>Encerrar esta proposta como {action==='withdraw'?'retirada':'recusada'}. Efeito físico: 0; nenhuma origem será associada ou libertada. O original e esta decisão ficam no histórico; voltar a tratar a origem exige nova conferência.</p>;
|
|
}
|
|
|
|
export default function WorkshopStockWorkspace({api,context,can}) {
|
|
const [data,setData]=useState(null),[error,setError]=useState(''),[reload,setReload]=useState(0),[page,setPage]=useState(1),[busy,setBusy]=useState(false),[preview,setPreview]=useState(null),[proposalPage,setProposalPage]=useState(1),[status,setStatus]=useState("all");
|
|
const [uncertain,setUncertain]=useState(false);const operation=useRef(null);if(!operation.current)operation.current=new WorkshopStockOperation(api);
|
|
const request=useRef(null),generation=useRef(0),unit=context.unit.id;
|
|
useEffect(()=>{setPreview(null);setBusy(false);request.current=null;},[context.token]);
|
|
useEffect(()=>{const n=++generation.current;setData(null);setError('');api(`/api/workshop-stock?page=${page}&proposal_page=${proposalPage}&status=${status}`).then(d=>{if(n===generation.current)setData(d);}).catch(e=>{if(n===generation.current)setError(e.message);});return()=>{generation.current++;};},[context.token,reload,page,proposalPage,status]);
|
|
async function execute(path,body,key,recover=false){
|
|
if(busy||(!recover&&uncertain))return;const n=generation.current;setBusy(true);setError('');
|
|
try{const result=recover?await operation.current.retry():await operation.current.send(path,body,key);if(n===generation.current){setPreview(result.impact?result:null);setReload(v=>v+1);}}
|
|
catch(e){if(n===generation.current)setError(operation.current.uncertain?'Resultado incerto. Recupere a mesma operação antes de continuar.':e.message);}
|
|
finally{if(n===generation.current){setUncertain(operation.current.uncertain);setBusy(false);}}
|
|
}
|
|
async function prepare(e,source){e.preventDefault();if(busy||uncertain)return;const body={...Object.fromEntries(new FormData(e.currentTarget)),entry_id:source.id,version:source.version};body.warehouse_id=Number(body.warehouse_id);
|
|
const allocation=body.mode==='allocate_existing_delivery',returning=body.mode==='return_existing_delivery',delivery=returning||allocation||body.mode==='link_existing_delivery';delete body.mode;if(delivery)body.movement_id=Number(body.movement_id);else delete body.movement_id;
|
|
const path=returning?'/api/workshop-stock/prepare-return':allocation?'/api/workshop-stock/prepare-allocation':delivery?'/api/workshop-stock/prepare-delivery':'/api/workshop-stock/prepare';
|
|
const text=JSON.stringify([path,body]);if(request.current?.text!==text)request.current={text,key:crypto.randomUUID()};return execute(path,body,request.current.key);
|
|
}
|
|
async function confirm(e,p){e.preventDefault();if(busy||uncertain)return;const body=Object.fromEntries(new FormData(e.currentTarget));if('received_in_warehouse' in body)body.received_in_warehouse=body.received_in_warehouse==='on';return execute(`/api/workshop-stock/${p.id}/confirm`,body,crypto.randomUUID());}
|
|
async function close(e,p,action){e.preventDefault();if(busy||uncertain)return;return execute(`/api/workshop-stock/${p.id}/${action}`,{...Object.fromEntries(new FormData(e.currentTarget)),state_version:p.state_version,source_hash:p.source_hash},crypto.randomUUID());}
|
|
const closureForm=(p,action)=><details><summary>{action==='withdraw'?'Retirar proposta própria':'Recusar proposta'}</summary><WorkshopStockClosureImpact action={action}/><form onSubmit={e=>close(e,p,action)}><label>Fundamento do encerramento<textarea name="reason" minLength={10} maxLength={1000} required/></label><button disabled={busy}>{action==='withdraw'?'Confirmar retirada sem movimento':'Confirmar recusa sem movimento'}</button></form></details>;
|
|
const impact=p=><WorkshopStockImpact impact={p.impact}/>;
|
|
return <section className="panel" data-sgf-operation-uncertain={uncertain?"true":undefined}><h2>Conferência de consumo de material</h2><p>Uma origem verificada não provoca saída automática. Entregas já abatidas e correcções exigem reconciliação própria.</p>
|
|
{error&&<p role="alert">{error}</p>}{!data&&!error&&<p role="status">A consultar origens autorizadas…</p>}
|
|
{uncertain&&<button disabled={busy} onClick={()=>execute(null,null,null,true)}>Recuperar a mesma operação</button>}<fieldset disabled={busy||uncertain} style={{border:0,padding:0}}><button disabled={busy} onClick={()=>setReload(n=>n+1)}>Actualizar</button>
|
|
{preview&&<div role="status"><h3>Proposta #{preview.id}</h3>{impact(preview)}<p>{preview.reason} · Evidência: {preview.evidence}</p><p>{preview.state&&preview.state!=='pending'?'Proposta já encerrada; a prévia é histórica.':'A confirmação é individual, por outra identidade autorizada.'}</p></div>}
|
|
{data&&<><p>{data.notice}</p><h3>Origens verificadas</h3>{data.sources.map(source=>{const d=typeof source.data==='string'?JSON.parse(source.data):source.data;return <article className="panel" key={source.id}><h4>Ordem #{source.order_id} · Origem #{source.id} / v{source.version}</h4><p>{d.resource} · {source.quantity} {d.unit} · {d.event} · {source.origin}</p>
|
|
{source.movement_id?<p>Esta origem já tem o movimento #{source.movement_id}. Uma nova versão exige conferência/correcção física separada, sem novo abatimento automático.</p>:can('workshop_stock.prepare',unit)&&<form onSubmit={e=>prepare(e,source)}><label>Tratamento<select name="mode" required defaultValue=""><option value="" disabled>Seleccione individualmente</option>{d.event!=="returned"&&<><option value="new_issue">Registar saída nova</option><option value="link_existing_delivery">Ligar saída já registada, sem novo abatimento</option><option value="allocate_existing_delivery">Associar este consumo à parte restante de uma entrega</option></>}{d.event==="returned"&&<option value="return_existing_delivery">Confirmar entrada da devolução não consumida</option>}</select></label><label>Entrega existente (apenas para ligação/devolução){source.deliveries?<select name="movement_id" defaultValue=""><option value="">Escolha a entrega individual</option>{source.deliveries.map(m=><option key={m.id} value={m.id}>{m.reference} · {milliQuantity(-m.quantity_milli)} UN</option>)}</select>:<input name="movement_id" type="number" min="1"/>}</label><label>Armazém autorizado{source.warehouses?<select name="warehouse_id" required defaultValue=""><option value="">Escolha o armazém</option>{source.warehouses.map(w=><option key={w.id} value={w.id}>{w.code} · {w.name}</option>)}</select>:<input name="warehouse_id" type="number" min="1" required/>}</label><label>Fundamento<textarea name="reason" minLength={10} maxLength={1000} required/></label><label>Evidência da entrega e do tratamento físico<input name="evidence" minLength={3} maxLength={500} required/></label><button disabled={busy}>Conferir impacto</button></form>}</article>;})}
|
|
<button disabled={busy||page<=1} onClick={()=>setPage(p=>p-1)}>Anterior</button><button disabled={busy||page>=data.pagination.last_page} onClick={()=>setPage(p=>p+1)}>Seguinte</button>
|
|
<h3>Histórico de propostas da unidade</h3><label>Estado<select value={status} onChange={e=>{setStatus(e.target.value);setProposalPage(1);}}><option value="all">Todas</option><option value="pending">Pendentes</option><option value="confirmed">Confirmadas</option>{data.closure_supported&&<><option value="withdrawn">Retiradas</option><option value="rejected">Recusadas</option></>}</select></label>{data.proposals.map(p=><article className="panel" key={p.id}><h4>Proposta #{p.id} · Origem #{p.entry_id} / v{p.entry_version}</h4>{impact(p)}<p>Preparador #{p.preparer_id} · {p.created_at}</p><p>{p.reason} · Evidência: {p.evidence}</p>
|
|
{p.closure?<p>Prévia arquivada, não executada. {p.state==='withdrawn'?'Retirada':'Recusada'} por #{p.closure.actor_id} em {p.closure.created_at}: {p.closure.reason}.</p>:p.movement_id?<p>Tratamento já registado: movimento #{p.movement_id}.</p>:<>{p.can_review&&can('workshop_stock.confirm',unit)?<><form onSubmit={e=>confirm(e,p)}>{p.impact.mode==="return_existing_delivery"&&<label><input type="checkbox" name="received_in_warehouse" required/>Confirmo a recepção física desta quantidade no armazém indicado</label>}<label>Fundamento da confirmação<textarea name="reason" minLength={10} maxLength={1000} required/></label><p>O servidor exige outra identidade e revalida origem, mapa, direitos e livro antes de gravar.</p><button disabled={busy}>Confirmar tratamento conferido</button></form>{data.closure_supported&&closureForm(p,'reject')}</>:<p>Aguarda confirmação independente.</p>}{p.can_withdraw&&can('workshop_stock.prepare',unit)&&closureForm(p,'withdraw')}</>}</article>)}<button disabled={busy||proposalPage<=1} onClick={()=>setProposalPage(p=>p-1)}>Propostas anteriores</button><button disabled={busy||proposalPage>=data.proposal_pagination.last_page} onClick={()=>setProposalPage(p=>p+1)}>Propostas seguintes</button></>}
|
|
</fieldset></section>;
|
|
}
|
|
|
|
|