Skip to content

Commit e19d114

Browse files
authored
Close canonical dashboard to 2025 Neon period (#38)
1 parent 57a23c9 commit e19d114

1 file changed

Lines changed: 3 additions & 2 deletions

File tree

‎src/pages/Canonical2025.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ const kg=(value:number)=>`${value.toLocaleString('es-CL',{maximumFractionDigits:
88
const n=(value:string|number|null|undefined)=>{const parsed=Number(value);return Number.isFinite(parsed)?parsed:0}
99
const key=(value:string|null|undefined)=>String(value??'').trim().toLocaleLowerCase('es-CL')
1010
const monthNames=['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']
11+
const yearOf=(record:HistoricalRecord)=>{const raw=record.event_date??record.production_date??record.process_date??record.reception_date;if(!raw)return null;const date=new Date(raw);return Number.isFinite(date.getTime())?date.getFullYear():null}
1112
function duplicateValues(records:HistoricalRecord[],selector:(record:HistoricalRecord)=>string|null){const counts=new Map<string,number>();for(const record of records){const value=key(selector(record));if(value)counts.set(value,(counts.get(value)??0)+1)}return [...counts.values()].filter(count=>count>1).length}
12-
function model(records:HistoricalRecord[]){const operational=records.filter(record=>record.record_status==='operational');const guideKg=operational.reduce((sum,row)=>sum+n(row.guide_kg),0),receivedKg=operational.reduce((sum,row)=>sum+n(row.received_kg),0);const providers=new Set(operational.map(row=>key(row.supplier_name??row.supplier_original)).filter(Boolean));const critical=operational.filter(row=>n(row.guide_kg)>0&&Math.abs(n(row.guide_kg)-n(row.received_kg))/n(row.guide_kg)>=.15);const over=operational.filter(row=>row.guide_kg!=null&&row.received_kg!=null&&n(row.received_kg)>n(row.guide_kg));const months=new Map<number,{guide:number;received:number}>();for(const row of operational){const raw=row.event_date??row.production_date??row.process_date??row.reception_date;if(!raw)continue;const date=new Date(raw);if(!Number.isFinite(date.getTime())||date.getFullYear()!==2025)continue;const month=date.getMonth(),entry=months.get(month)??{guide:0,received:0};entry.guide+=n(row.guide_kg);entry.received+=n(row.received_kg);months.set(month,entry)}const monthly=[...months.entries()].sort(([a],[b])=>a-b).map(([month,value])=>({month:monthNames[month],...value,difference:value.guide>0?((value.guide-value.received)/value.guide)*100:0}));const providerMap=new Map<string,{name:string;lots:number;guide:number;received:number;rejectionWeighted:number;rejectionWeight:number}>();for(const row of operational){const name=row.supplier_name??row.supplier_original??'Proveedor no identificado',id=key(name),entry=providerMap.get(id)??{name,lots:0,guide:0,received:0,rejectionWeighted:0,rejectionWeight:0};const guide=n(row.guide_kg),rejection=row.yields?.rechazo;entry.lots++;entry.guide+=guide;entry.received+=n(row.received_kg);if(rejection!=null&&Number.isFinite(Number(rejection))&&guide>0){entry.rejectionWeighted+=Number(rejection)*guide;entry.rejectionWeight+=guide}providerMap.set(id,entry)}const providerPerformance=[...providerMap.values()].sort((a,b)=>b.guide-a.guide).slice(0,5).map(item=>({name:item.name,lots:item.lots,guide:item.guide,difference:item.guide>0?((item.guide-item.received)/item.guide)*100:0,rejection:item.rejectionWeight>0?(item.rejectionWeighted/item.rejectionWeight)*100:0}));const exceptions=[...operational].map(row=>{const guide=n(row.guide_kg),received=n(row.received_kg),ratio=guide>0?Math.abs(guide-received)/guide:0,text=(row.observations??'').toLowerCase();const unsafe=/(vidrio|tijereta|plastico|plástico)/.test(text),receivedOver=guide>0&&received>guide;return {row,score:unsafe?4:receivedOver?3:ratio>=.15?2:/(rechaz|respaldo|mala calidad|sin producto)/.test(text)?1:0,type:unsafe?'Inocuidad':receivedOver?'Peso recibido mayor a guía':ratio>=.15?'Diferencia de peso ≥15%':'Observación de fuente'}}).filter(item=>item.score>0).sort((a,b)=>b.score-a.score||a.row.source_row-b.row.source_row).slice(0,4).map(item=>({id:`EX-${item.row.source_row}`,lot:item.row.lot_code,type:item.type,detail:`${item.row.guide_kg==null?'Sin kg guía':kg(n(item.row.guide_kg))} · ${item.row.received_kg==null?'sin kg recibidos':kg(n(item.row.received_kg))}${item.row.observations?` · ${item.row.observations}`:''}`,severity:item.score>=3?'Crítica':'Alta'}));return {records:records.length,operational:operational.length,guideKg,receivedKg,differenceKg:guideKg-receivedKg,providers:providers.size,duplicateGuides:duplicateValues(operational,row=>row.guide_number),duplicateLots:duplicateValues(operational,row=>row.lot_code),negativeDifferences:over.length,criticalDifferences:critical.length,monthly,providerPerformance,exceptions}}
13-
export function Canonical2025(){const {records,loading,error}=useLot360();const data=useMemo(()=>model(records),[records]);const max=Math.max(1,...data.monthly.map(item=>item.guide));if(loading&&!records.length)return <><PageHeader eyebrow="Fuente canónica · Neon" title="Operación productiva real" description="Calculando el período directamente desde la base canónica."/><div className="panel empty-state"><Database size={28}/><h3>Sincronizando datos canónicos…</h3></div></>;if(error&&!records.length)return <><PageHeader eyebrow="Fuente canónica · Neon" title="Operación productiva real" description="No mostramos cifras estáticas cuando la fuente real no está disponible."/><div className="notice error">{error}</div></>;return <><PageHeader eyebrow="Fuente canónica · Neon" title="Operación productiva real" description="Todos los indicadores se calculan desde los registros canónicos vigentes; una reimportación validada actualiza esta vista sin mantener una copia paralela."/><section className="metric-grid canonical-metrics"><Metric icon={<Database/>} label="Registros" value={String(data.records)} note={`${data.operational} operacionales · ${data.records-data.operational} anulados`}/><Metric icon={<Scale/>} label="Kilos guía" value={kg(data.guideKg)} note={`${kg(data.receivedKg)} recibidos`}/><Metric icon={<FileWarning/>} label="Diferencia registrada" value={kg(data.differenceKg)} note={data.guideKg?`${((data.differenceKg/data.guideKg)*100).toFixed(1)}% sobre kilos guía`:'Sin base de kilos guía'} warning/><Metric icon={<Users/>} label="Proveedores" value={String(data.providers)} note="Derivados de la fuente canónica"/></section><section className="canonical-focus"><article className="panel monthly-panel"><header className="panel-header"><h2>Recepción mensual</h2><span>Guía vs. recibido</span></header>{data.monthly.length?<><div className="month-chart">{data.monthly.map(item=><div className="month-column" key={item.month}><div className="month-bars"><i style={{height:`${item.guide/max*100}%`}}/><i className="received" style={{height:`${item.received/max*100}%`}}/></div><b>{item.month}</b><small>{item.difference.toFixed(1)}%</small></div>)}</div><div className="chart-legend"><span><i/>Kilos guía</span><span><i className="received"/>Kilos recibidos</span></div></>:<div className="empty-state"><span>Sin fechas válidas para construir la serie.</span></div>}</article></section><section className="analysis-disclosures"><Disclosure title="Calidad de datos" meta={`${data.criticalDifferences} diferencias críticas`}><div className="audit-panel"><Audit value={data.duplicateGuides} label="Guías repetidas"/><Audit value={data.duplicateLots} label="Lotes repetidos"/><Audit value={data.negativeDifferences} label="Recepciones sobre guía"/><Audit value={data.criticalDifferences} label="Diferencias de 15% o más" danger/></div></Disclosure><Disclosure title="Desempeño por proveedor" meta={`${data.providerPerformance.length} proveedores principales`}><div className="table-scroll"><table className="data-table"><thead><tr><th>Proveedor</th><th className="numeric">Lotes</th><th className="numeric">Kilos guía</th><th className="numeric">Diferencia</th><th className="numeric">Rechazo / guía</th></tr></thead><tbody>{data.providerPerformance.map(provider=><tr key={provider.name}><td><b>{provider.name}</b></td><td className="numeric">{provider.lots}</td><td className="numeric">{kg(provider.guide)}</td><td className={`numeric ${provider.difference>8?'negative':''}`}>{provider.difference>0?<ArrowUpRight size={12}/>:<ArrowDownRight size={12}/>} {Math.abs(provider.difference).toFixed(2)}%</td><td className={`numeric ${provider.rejection>15?'negative':''}`}>{provider.rejection.toFixed(2)}%</td></tr>)}</tbody></table></div></Disclosure><Disclosure title="Excepciones documentadas" meta={`${data.exceptions.length} casos prioritarios`}><div className="canonical-exceptions">{data.exceptions.map(item=><div key={item.id}><AlertTriangle size={16}/><span><b>{item.type}</b><small>{item.lot} · {item.detail}</small></span><em className={item.severity==='Crítica'?'critical':''}>{item.severity}</em></div>)}</div></Disclosure></section></>}
13+
function model(records:HistoricalRecord[]){const period=records.filter(record=>yearOf(record)===2025);const operational=period.filter(record=>record.record_status==='operational');const guideKg=operational.reduce((sum,row)=>sum+n(row.guide_kg),0),receivedKg=operational.reduce((sum,row)=>sum+n(row.received_kg),0);const providers=new Set(operational.map(row=>key(row.supplier_name??row.supplier_original)).filter(Boolean));const critical=operational.filter(row=>n(row.guide_kg)>0&&Math.abs(n(row.guide_kg)-n(row.received_kg))/n(row.guide_kg)>=.15);const over=operational.filter(row=>row.guide_kg!=null&&row.received_kg!=null&&n(row.received_kg)>n(row.guide_kg));const months=new Map<number,{guide:number;received:number}>();for(const row of operational){const raw=row.event_date??row.production_date??row.process_date??row.reception_date;if(!raw)continue;const date=new Date(raw);if(!Number.isFinite(date.getTime()))continue;const month=date.getMonth(),entry=months.get(month)??{guide:0,received:0};entry.guide+=n(row.guide_kg);entry.received+=n(row.received_kg);months.set(month,entry)}const monthly=[...months.entries()].sort(([a],[b])=>a-b).map(([month,value])=>({month:monthNames[month],...value,difference:value.guide>0?((value.guide-value.received)/value.guide)*100:0}));const providerMap=new Map<string,{name:string;lots:number;guide:number;received:number;rejectionWeighted:number;rejectionWeight:number}>();for(const row of operational){const name=row.supplier_name??row.supplier_original??'Proveedor no identificado',id=key(name),entry=providerMap.get(id)??{name,lots:0,guide:0,received:0,rejectionWeighted:0,rejectionWeight:0};const guide=n(row.guide_kg),rejection=row.yields?.rechazo;entry.lots++;entry.guide+=guide;entry.received+=n(row.received_kg);if(rejection!=null&&Number.isFinite(Number(rejection))&&guide>0){entry.rejectionWeighted+=Number(rejection)*guide;entry.rejectionWeight+=guide}providerMap.set(id,entry)}const providerPerformance=[...providerMap.values()].sort((a,b)=>b.guide-a.guide).slice(0,5).map(item=>({name:item.name,lots:item.lots,guide:item.guide,difference:item.guide>0?((item.guide-item.received)/item.guide)*100:0,rejection:item.rejectionWeight>0?(item.rejectionWeighted/item.rejectionWeight)*100:0}));const exceptions=[...operational].map(row=>{const guide=n(row.guide_kg),received=n(row.received_kg),ratio=guide>0?Math.abs(guide-received)/guide:0,text=(row.observations??'').toLowerCase();const unsafe=/(vidrio|tijereta|plastico|plástico)/.test(text),receivedOver=guide>0&&received>guide;return {row,score:unsafe?4:receivedOver?3:ratio>=.15?2:/(rechaz|respaldo|mala calidad|sin producto)/.test(text)?1:0,type:unsafe?'Inocuidad':receivedOver?'Peso recibido mayor a guía':ratio>=.15?'Diferencia de peso ≥15%':'Observación de fuente'}}).filter(item=>item.score>0).sort((a,b)=>b.score-a.score||a.row.source_row-b.row.source_row).slice(0,4).map(item=>({id:`EX-${item.row.source_row}`,lot:item.row.lot_code,type:item.type,detail:`${item.row.guide_kg==null?'Sin kg guía':kg(n(item.row.guide_kg))} · ${item.row.received_kg==null?'sin kg recibidos':kg(n(item.row.received_kg))}${item.row.observations?` · ${item.row.observations}`:''}`,severity:item.score>=3?'Crítica':'Alta'}));return {records:period.length,operational:operational.length,guideKg,receivedKg,differenceKg:guideKg-receivedKg,providers:providers.size,duplicateGuides:duplicateValues(operational,row=>row.guide_number),duplicateLots:duplicateValues(operational,row=>row.lot_code),negativeDifferences:over.length,criticalDifferences:critical.length,monthly,providerPerformance,exceptions}}
14+
export function Canonical2025(){const {records,loading,error}=useLot360();const data=useMemo(()=>model(records),[records]);const max=Math.max(1,...data.monthly.map(item=>item.guide));if(loading&&!records.length)return <><PageHeader eyebrow="Fuente canónica · Neon · 2025" title="Operación productiva real" description="Calculando el período 2025 directamente desde la base canónica."/><div className="panel empty-state"><Database size={28}/><h3>Sincronizando datos canónicos…</h3></div></>;if(error&&!records.length)return <><PageHeader eyebrow="Fuente canónica · Neon · 2025" title="Operación productiva real" description="No mostramos cifras estáticas cuando la fuente real no está disponible."/><div className="notice error">{error}</div></>;return <><PageHeader eyebrow="Fuente canónica · Neon · 2025" title="Operación productiva real" description="Todos los indicadores están cerrados al período 2025 y se calculan desde Neon; los nombres históricos de planta se conservan tal como vienen de la fuente."/><section className="metric-grid canonical-metrics"><Metric icon={<Database/>} label="Registros" value={String(data.records)} note={`${data.operational} operacionales · ${data.records-data.operational} anulados`}/><Metric icon={<Scale/>} label="Kilos guía" value={kg(data.guideKg)} note={`${kg(data.receivedKg)} recibidos`}/><Metric icon={<FileWarning/>} label="Diferencia registrada" value={kg(data.differenceKg)} note={data.guideKg?`${((data.differenceKg/data.guideKg)*100).toFixed(1)}% sobre kilos guía`:'Sin base de kilos guía'} warning/><Metric icon={<Users/>} label="Proveedores" value={String(data.providers)} note="Derivados de la fuente canónica"/></section><section className="canonical-focus"><article className="panel monthly-panel"><header className="panel-header"><h2>Recepción mensual</h2><span>Guía vs. recibido</span></header>{data.monthly.length?<><div className="month-chart">{data.monthly.map(item=><div className="month-column" key={item.month}><div className="month-bars"><i style={{height:`${item.guide/max*100}%`}}/><i className="received" style={{height:`${item.received/max*100}%`}}/></div><b>{item.month}</b><small>{item.difference.toFixed(1)}%</small></div>)}</div><div className="chart-legend"><span><i/>Kilos guía</span><span><i className="received"/>Kilos recibidos</span></div></>:<div className="empty-state"><span>Sin fechas válidas para construir la serie.</span></div>}</article></section><section className="analysis-disclosures"><Disclosure title="Calidad de datos" meta={`${data.criticalDifferences} diferencias críticas`}><div className="audit-panel"><Audit value={data.duplicateGuides} label="Guías repetidas"/><Audit value={data.duplicateLots} label="Lotes repetidos"/><Audit value={data.negativeDifferences} label="Recepciones sobre guía"/><Audit value={data.criticalDifferences} label="Diferencias de 15% o más" danger/></div></Disclosure><Disclosure title="Desempeño por proveedor" meta={`${data.providerPerformance.length} proveedores principales`}><div className="table-scroll"><table className="data-table"><thead><tr><th>Proveedor</th><th className="numeric">Lotes</th><th className="numeric">Kilos guía</th><th className="numeric">Diferencia</th><th className="numeric">Rechazo / guía</th></tr></thead><tbody>{data.providerPerformance.map(provider=><tr key={provider.name}><td><b>{provider.name}</b></td><td className="numeric">{provider.lots}</td><td className="numeric">{kg(provider.guide)}</td><td className={`numeric ${provider.difference>8?'negative':''}`}>{provider.difference>0?<ArrowUpRight size={12}/>:<ArrowDownRight size={12}/>} {Math.abs(provider.difference).toFixed(2)}%</td><td className={`numeric ${provider.rejection>15?'negative':''}`}>{provider.rejection.toFixed(2)}%</td></tr>)}</tbody></table></div></Disclosure><Disclosure title="Excepciones documentadas" meta={`${data.exceptions.length} casos prioritarios`}><div className="canonical-exceptions">{data.exceptions.map(item=><div key={item.id}><AlertTriangle size={16}/><span><b>{item.type}</b><small>{item.lot} · {item.detail}</small></span><em className={item.severity==='Crítica'?'critical':''}>{item.severity}</em></div>)}</div></Disclosure></section></>}
1415
function Disclosure({title,meta,children}:{title:string;meta:string;children:React.ReactNode}){return <details className="panel analysis-disclosure"><summary><span><b>{title}</b><small>{meta}</small></span><ChevronDown size={18}/></summary><div className="disclosure-body">{children}</div></details>}
1516
function Metric({icon,label,value,note,warning=false}:{icon:React.ReactNode;label:string;value:string;note:string;warning?:boolean}){return <article className="metric"><div className={`metric-icon ${warning?'warning':''}`}>{icon}</div><span>{label}</span><strong>{value}</strong><small className={warning?'warning-text':''}>{note}</small></article>}
1617
function Audit({value,label,danger=false}:{value:number;label:string;danger?:boolean}){return <div className={`audit-row ${danger?'danger':''}`}><b>{value}</b><span>{label}</span></div>}

0 commit comments

Comments
 (0)