Skip to content

Commit 30a6664

Browse files
committed
Make settlements read-only for operations management
1 parent dcd1adf commit 30a6664

1 file changed

Lines changed: 1 addition & 1 deletion

File tree

‎src/pages/Settlements.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ import {useAuth} from "../auth";
55
import {PageHeader} from "../components/PageHeader";
66
type Settlement={id:string;status:'pending'|'approved'|'rejected';gross_amount_clp:string;other_deductions_clp:string;credit_recovery_clp:string;net_amount_clp:string|null;price_per_kg_clp:string;created_at:string;settled_at:string|null;created_by:string;approved_by:string|null;reception_number:string;guide_kg:string;accepted_kg:string;species:string;supplier:string};type Eligible={id:string;reception_number:string;guide_kg:string;accepted_kg:string;species:string;received_at:string;supplier:string;credit_balance_clp:string};type Payload={settlements?:Settlement[];eligible?:Eligible[];error?:string};const money=(value:number)=>value.toLocaleString("es-CL",{style:"currency",currency:"CLP",maximumFractionDigits:0});const statusLabel={pending:"Pendiente",approved:"Aprobada",rejected:"Rechazada"} as const;
77
async function fetchSettlements(){const response=await fetch("/api/settlements"),payload=await response.json() as Payload;if(!response.ok)throw new Error(payload.error??"No fue posible cargar liquidaciones");return {settlements:payload.settlements??[],eligible:payload.eligible??[]}}
8-
export function Settlements(){const {operator}=useAuth(),[loading,setLoading]=useState(true),[saving,setSaving]=useState(false),[error,setError]=useState(""),[settlements,setSettlements]=useState<Settlement[]>([]),[eligible,setEligible]=useState<Eligible[]>([]),[receptionId,setReceptionId]=useState(""),[price,setPrice]=useState(0),[deductions,setDeductions]=useState(0),[comment,setComment]=useState("");const selected=eligible.find(item=>item.id===receptionId),gross=selected?Math.round(Number(selected.accepted_kg)*price):0,payable=Math.max(0,gross-deductions),totals=useMemo(()=>settlements.reduce((acc,item)=>({gross:acc.gross+Number(item.gross_amount_clp),recovery:acc.recovery+Number(item.credit_recovery_clp),net:acc.net+Number(item.net_amount_clp??0)}),{gross:0,recovery:0,net:0}),[settlements]);function applyData(data:{settlements:Settlement[];eligible:Eligible[]}){setSettlements(data.settlements);setEligible(data.eligible);setReceptionId(current=>data.eligible.some(item=>item.id===current)?current:(data.eligible[0]?.id??""))}async function reload(){setLoading(true);setError("");try{applyData(await fetchSettlements())}catch(cause){setError(cause instanceof Error?cause.message:"No fue posible cargar liquidaciones")}finally{setLoading(false)}}useEffect(()=>{let active=true;fetchSettlements().then(data=>{if(active){setSettlements(data.settlements);setEligible(data.eligible);setReceptionId(data.eligible[0]?.id??"")}}).catch(cause=>{if(active)setError(cause instanceof Error?cause.message:"No fue posible cargar liquidaciones")}).finally(()=>{if(active)setLoading(false)});return()=>{active=false}},[]);async function submit(event:FormEvent){event.preventDefault();setSaving(true);setError("");try{const response=await fetch("/api/settlements",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({receptionId,pricePerKg:price,otherDeductions:deductions,comment})}),payload=await response.json() as Payload;if(!response.ok)throw new Error(payload.error??"No fue posible crear la liquidación");setPrice(0);setDeductions(0);setComment("");await reload()}catch(cause){setError(cause instanceof Error?cause.message:"No fue posible crear la liquidación")}finally{setSaving(false)}}return <><PageHeader eyebrow="Cierre económico" title="Liquidaciones" description={`Sesión financiera: ${operator?.fullName??''}. Los cálculos y envíos quedan firmados automáticamente.`}/><section className="settlement-summary"><Summary label="Liquidaciones" value={String(settlements.length)}/><Summary label="Monto bruto" value={money(totals.gross)}/><Summary label="Anticipos recuperados" value={money(totals.recovery)}/><Summary label="Pago neto aprobado" value={money(totals.net)}/></section>{error?<div className="notice error" role="alert">{error}</div>:null}<section className="settlement-layout"><form className="panel settlement-builder" onSubmit={submit}><header className="panel-header"><div><span className="overline teal">Nueva liquidación</span><h2>Calcular desde recepción</h2></div><Calculator size={20}/></header>{eligible.length?<><div className="form-grid"><label className="full-field">Recepción aprobada<select required value={receptionId} onChange={event=>setReceptionId(event.target.value)}>{eligible.map(item=><option value={item.id} key={item.id}>REC-{item.reception_number} · {item.supplier} · {Number(item.accepted_kg).toLocaleString("es-CL")} kg</option>)}</select></label><label>Precio por kg (CLP)<input type="number" min="1" step="1" required value={price||""} onChange={event=>setPrice(Number(event.target.value))}/></label><label>Otros descuentos (CLP)<input type="number" min="0" step="1" value={deductions||""} onChange={event=>setDeductions(Number(event.target.value))}/></label><label className="full-field">Comentario de cálculo<input required minLength={3} value={comment} onChange={event=>setComment(event.target.value)} placeholder="Precio acordado y respaldo"/></label></div><div className="settlement-calculation"><div><small>Peso guía</small><b>{Number(selected?.guide_kg??0).toLocaleString("es-CL")} kg</b></div><div><small>Peso aceptado</small><b>{Number(selected?.accepted_kg??0).toLocaleString("es-CL")} kg</b></div><div><small>Monto bruto</small><b>{money(gross)}</b></div><div><small>Antes de anticipo</small><b>{money(payable)}</b></div><div><small>Saldo documentado</small><b>{money(Number(selected?.credit_balance_clp??0))}</b></div></div><button className="button primary full" disabled={saving}>{saving?"Enviando…":"Enviar a aprobación"}<ArrowRight size={15}/></button></>:loading?<div className="empty-state"><LoaderCircle className="spin"/></div>:<div className="empty-state"><CheckCircle2 size={28}/><h3>Sin recepciones por liquidar</h3><p>Solo aparecen recepciones aprobadas que todavía no tienen liquidación.</p></div>}</form><article className="panel settlement-history"><header className="panel-header"><h2>Historial real</h2><span>{loading?"Actualizando…":`${settlements.length} registros`}</span></header>{settlements.length?<div className="settlement-list">{settlements.map(item=><div key={item.id}><span className={`settlement-status ${item.status}`}><ReceiptText size={15}/></span><div><b>LIQ-{item.reception_number} · {item.supplier}</b><small>{item.species} · {money(Number(item.gross_amount_clp))} bruto</small></div><div className="numeric"><b>{item.net_amount_clp==null?"Por aprobar":money(Number(item.net_amount_clp))}</b><small>Recuperación {money(Number(item.credit_recovery_clp))}</small></div><em className={`status ${item.status==='pending'?'warning':item.status==='approved'?'success':'danger'}`}>{statusLabel[item.status]}</em></div>)}</div>:<div className="empty-state"><Landmark/><h3>Sin liquidaciones</h3><p>El historial comienza con la primera recepción aprobada y valorizada.</p></div>}</article></section><div className="settlement-next"><p>Las liquidaciones pendientes requieren una segunda decisión de Finanzas o Administración.</p><Link className="button secondary" to="/aprobaciones">Ir a decisiones <ArrowRight size={14}/></Link></div></>}
8+
export function Settlements(){const {operator}=useAuth(),canCreate=operator?.role==="admin"||operator?.role==="finance",[loading,setLoading]=useState(true),[saving,setSaving]=useState(false),[error,setError]=useState(""),[settlements,setSettlements]=useState<Settlement[]>([]),[eligible,setEligible]=useState<Eligible[]>([]),[receptionId,setReceptionId]=useState(""),[price,setPrice]=useState(0),[deductions,setDeductions]=useState(0),[comment,setComment]=useState("");const selected=eligible.find(item=>item.id===receptionId),gross=selected?Math.round(Number(selected.accepted_kg)*price):0,payable=Math.max(0,gross-deductions),totals=useMemo(()=>settlements.reduce((acc,item)=>({gross:acc.gross+Number(item.gross_amount_clp),recovery:acc.recovery+Number(item.credit_recovery_clp),net:acc.net+Number(item.net_amount_clp??0)}),{gross:0,recovery:0,net:0}),[settlements]);function applyData(data:{settlements:Settlement[];eligible:Eligible[]}){setSettlements(data.settlements);setEligible(data.eligible);setReceptionId(current=>data.eligible.some(item=>item.id===current)?current:(data.eligible[0]?.id??""))}async function reload(){setLoading(true);setError("");try{applyData(await fetchSettlements())}catch(cause){setError(cause instanceof Error?cause.message:"No fue posible cargar liquidaciones")}finally{setLoading(false)}}useEffect(()=>{let active=true;fetchSettlements().then(data=>{if(active){setSettlements(data.settlements);setEligible(data.eligible);setReceptionId(data.eligible[0]?.id??"")}}).catch(cause=>{if(active)setError(cause instanceof Error?cause.message:"No fue posible cargar liquidaciones")}).finally(()=>{if(active)setLoading(false)});return()=>{active=false}},[]);async function submit(event:FormEvent){event.preventDefault();if(!canCreate)return;setSaving(true);setError("");try{const response=await fetch("/api/settlements",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({receptionId,pricePerKg:price,otherDeductions:deductions,comment})}),payload=await response.json() as Payload;if(!response.ok)throw new Error(payload.error??"No fue posible crear la liquidación");setPrice(0);setDeductions(0);setComment("");await reload()}catch(cause){setError(cause instanceof Error?cause.message:"No fue posible crear la liquidación")}finally{setSaving(false)}}return <><PageHeader eyebrow="Cierre económico" title="Liquidaciones" description={canCreate?`Sesión financiera: ${operator?.fullName??''}. Los cálculos y envíos quedan firmados automáticamente.`:"Vista gerencial de liquidaciones y recuperación de anticipos dentro de tu alcance de plantas."}/><section className="settlement-summary"><Summary label="Liquidaciones" value={String(settlements.length)}/><Summary label="Monto bruto" value={money(totals.gross)}/><Summary label="Anticipos recuperados" value={money(totals.recovery)}/><Summary label="Pago neto aprobado" value={money(totals.net)}/></section>{error?<div className="notice error" role="alert">{error}</div>:null}<section className="settlement-layout">{canCreate?<form className="panel settlement-builder" onSubmit={submit}><header className="panel-header"><div><span className="overline teal">Nueva liquidación</span><h2>Calcular desde recepción</h2></div><Calculator size={20}/></header>{eligible.length?<><div className="form-grid"><label className="full-field">Recepción aprobada<select required value={receptionId} onChange={event=>setReceptionId(event.target.value)}>{eligible.map(item=><option value={item.id} key={item.id}>REC-{item.reception_number} · {item.supplier} · {Number(item.accepted_kg).toLocaleString("es-CL")} kg</option>)}</select></label><label>Precio por kg (CLP)<input type="number" min="1" step="1" required value={price||""} onChange={event=>setPrice(Number(event.target.value))}/></label><label>Otros descuentos (CLP)<input type="number" min="0" step="1" value={deductions||""} onChange={event=>setDeductions(Number(event.target.value))}/></label><label className="full-field">Comentario de cálculo<input required minLength={3} value={comment} onChange={event=>setComment(event.target.value)} placeholder="Precio acordado y respaldo"/></label></div><div className="settlement-calculation"><div><small>Peso guía</small><b>{Number(selected?.guide_kg??0).toLocaleString("es-CL")} kg</b></div><div><small>Peso aceptado</small><b>{Number(selected?.accepted_kg??0).toLocaleString("es-CL")} kg</b></div><div><small>Monto bruto</small><b>{money(gross)}</b></div><div><small>Antes de anticipo</small><b>{money(payable)}</b></div><div><small>Saldo documentado</small><b>{money(Number(selected?.credit_balance_clp??0))}</b></div></div><button className="button primary full" disabled={saving}>{saving?"Enviando…":"Enviar a aprobación"}<ArrowRight size={15}/></button></>:loading?<div className="empty-state"><LoaderCircle className="spin"/></div>:<div className="empty-state"><CheckCircle2 size={28}/><h3>Sin recepciones por liquidar</h3><p>Solo aparecen recepciones aprobadas que todavía no tienen liquidación.</p></div>}</form>:<article className="panel settlement-builder"><header className="panel-header"><div><span className="overline teal">Vista gerencial</span><h2>Liquidaciones bajo seguimiento</h2></div><Landmark size={20}/></header><div className="empty-state"><CheckCircle2 size={28}/><h3>Acceso de supervisión</h3><p>Puedes revisar montos, recuperaciones y estados. La creación de liquidaciones permanece reservada a Finanzas y Administración.</p></div></article>}<article className="panel settlement-history"><header className="panel-header"><h2>Historial real</h2><span>{loading?"Actualizando…":`${settlements.length} registros`}</span></header>{settlements.length?<div className="settlement-list">{settlements.map(item=><div key={item.id}><span className={`settlement-status ${item.status}`}><ReceiptText size={15}/></span><div><b>LIQ-{item.reception_number} · {item.supplier}</b><small>{item.species} · {money(Number(item.gross_amount_clp))} bruto</small></div><div className="numeric"><b>{item.net_amount_clp==null?"Por aprobar":money(Number(item.net_amount_clp))}</b><small>Recuperación {money(Number(item.credit_recovery_clp))}</small></div><em className={`status ${item.status==='pending'?'warning':item.status==='approved'?'success':'danger'}`}>{statusLabel[item.status]}</em></div>)}</div>:<div className="empty-state"><Landmark/><h3>Sin liquidaciones</h3><p>El historial comienza con la primera recepción aprobada y valorizada.</p></div>}</article></section><div className="settlement-next"><p>{canCreate?"Las liquidaciones pendientes requieren una segunda decisión de Finanzas o Administración.":"Gerencia de Operaciones puede revisar el cierre económico sin alterar decisiones financieras."}</p><Link className="button secondary" to="/aprobaciones">Ir a decisiones <ArrowRight size={14}/></Link></div></>}
99
function Summary({label,value}:{label:string;value:string}){return <article><small>{label}</small><b>{value}</b></article>}

0 commit comments

Comments
 (0)