77 X ,
88} from "lucide-react" ;
99import { useRef , useState } from "react" ;
10+ import { publishHistoricalProduction , readHistoricalProductionWorkbook , type HistoricalWorkbookPreview } from "../historicalImport" ;
1011import type { Plant } from "../plants" ;
1112import { readPlantWorkbook , type ValidatedImport } from "../plantImport" ;
1213
@@ -19,6 +20,7 @@ type Props = {
1920export function PlantImportModal ( { open, plants, onClose, onPublish } : Props ) {
2021 const input = useRef < HTMLInputElement > ( null ) ;
2122 const [ rows , setRows ] = useState < ValidatedImport [ ] > ( [ ] ) ;
23+ const [ historical , setHistorical ] = useState < HistoricalWorkbookPreview | null > ( null ) ;
2224 const [ fileName , setFileName ] = useState ( "" ) ;
2325 const [ busy , setBusy ] = useState ( false ) ;
2426 const [ message , setMessage ] = useState ( "" ) ;
@@ -27,8 +29,10 @@ export function PlantImportModal({ open, plants, onClose, onPublish }: Props) {
2729 const invalid = rows . length - valid . length ;
2830 const reset = ( ) => {
2931 setRows ( [ ] ) ;
32+ setHistorical ( null ) ;
3033 setFileName ( "" ) ;
3134 setMessage ( "" ) ;
35+ if ( input . current ) input . current . value = '' ;
3236 } ;
3337 const close = ( ) => {
3438 reset ( ) ;
@@ -39,16 +43,22 @@ export function PlantImportModal({ open, plants, onClose, onPublish }: Props) {
3943 setBusy ( true ) ;
4044 setMessage ( "" ) ;
4145 try {
46+ const native = await readHistoricalProductionWorkbook ( file ) ;
47+ if ( native ) {
48+ setHistorical ( native ) ;
49+ setRows ( [ ] ) ;
50+ setFileName ( file . name ) ;
51+ return ;
52+ }
4253 const parsed = await readPlantWorkbook ( file , plants ) ;
54+ setHistorical ( null ) ;
4355 setRows ( parsed ) ;
4456 setFileName ( file . name ) ;
45- if ( ! parsed . length )
46- setMessage ( "La primera hoja no contiene filas de datos." ) ;
47- } catch {
57+ if ( ! parsed . length ) setMessage ( "La primera hoja no contiene filas de datos." ) ;
58+ } catch ( cause ) {
4859 setRows ( [ ] ) ;
49- setMessage (
50- "No fue posible leer el archivo. Verifica que sea XLSX, XLS o CSV." ,
51- ) ;
60+ setHistorical ( null ) ;
61+ setMessage ( cause instanceof Error ?cause . message :"No fue posible leer el archivo. Verifica que sea XLSX o CSV." ) ;
5262 } finally {
5363 setBusy ( false ) ;
5464 }
@@ -61,161 +71,62 @@ export function PlantImportModal({ open, plants, onClose, onPublish }: Props) {
6171 await onPublish ( valid ) ;
6272 close ( ) ;
6373 } catch ( cause ) {
64- setMessage (
65- cause instanceof Error ? cause . message : "No fue posible publicar" ,
66- ) ;
74+ setMessage ( cause instanceof Error ? cause . message : "No fue posible publicar" ) ;
6775 } finally {
6876 setBusy ( false ) ;
6977 }
7078 } ;
79+ const publishHistorical = async ( ) => {
80+ if ( ! historical ?. rows . length ) return ;
81+ setBusy ( true ) ; setMessage ( '' ) ;
82+ try {
83+ const result = await publishHistoricalProduction ( historical ) ;
84+ setMessage ( `Publicación completa: ${ result . inserted ?? 0 } nuevas · ${ result . duplicates ?? 0 } ya existentes.` ) ;
85+ window . setTimeout ( close , 900 ) ;
86+ } catch ( cause ) { setMessage ( cause instanceof Error ?cause . message :'No fue posible publicar' ) } finally { setBusy ( false ) }
87+ } ;
7188 const sample =
7289 "planta,periodo,produccion_kg,meta_kg,inventario_kg,producto_terminado_kg,fecha_actualizacion,observacion\nancud,2026-08,18420,18000,6250,3120,2026-08-19T08:45:00,Operación normal\nquellon,2026-08,26880,28000,9340,4820,2026-08-19T09:10:00,Sin novedades" ;
90+ const hasData = rows . length > 0 || historical ;
7391 return (
7492 < div className = "modal-backdrop" role = "dialog" aria-modal = "true" >
7593 < section className = "modal-panel import-modal" >
7694 < header >
7795 < div >
7896 < span className = "overline teal" > Importación operacional</ span >
79- < h2 > Actualizar plantas desde planilla</ h2 >
80- < p > Validamos los datos antes de modificar el centro de control.</ p >
97+ < h2 > { historical ? 'Producción Pescamar reconocida' : 'Actualizar desde planilla' } </ h2 >
98+ < p > { historical ? 'Detectamos el formato nativo por guía y lote. Se conserva cada fila y sus observaciones de calidad.' : ' Validamos los datos antes de modificar el centro de control.' } </ p >
8199 </ div >
82- < button className = "icon-btn" onClick = { close } aria-label = "Cerrar" >
83- < X />
84- </ button >
100+ < button className = "icon-btn" onClick = { close } aria-label = "Cerrar" > < X /> </ button >
85101 </ header >
86- { ! rows . length ? (
102+ { ! hasData ? (
87103 < >
88- < button
89- className = "import-dropzone"
90- onClick = { ( ) => input . current ?. click ( ) }
91- disabled = { busy }
92- >
104+ < button className = "import-dropzone" onClick = { ( ) => input . current ?. click ( ) } disabled = { busy } >
93105 < Upload />
94- < span >
95- < b > { busy ? "Leyendo archivo…" : "Seleccionar Excel o CSV" } </ b >
96- < small > Formatos aceptados: .xlsx y .csv · máximo 15 MB</ small >
97- </ span >
106+ < span > < b > { busy ? "Leyendo archivo…" : "Seleccionar Excel o CSV" } </ b > < small > Auto-detecta producción Pescamar o snapshot por planta · máximo 15 MB</ small > </ span >
98107 </ button >
99- < input
100- ref = { input }
101- hidden
102- type = "file"
103- accept = ".xlsx,.csv"
104- onChange = { ( event ) => void choose ( event . target . files ?. [ 0 ] ) }
105- />
106- < div className = "import-contract" >
107- < FileSpreadsheet />
108- < div >
109- < b > Columnas obligatorias</ b >
110- < p >
111- planta, periodo, produccion_kg, meta_kg, inventario_kg,
112- producto_terminado_kg y fecha_actualizacion.
113- </ p >
114- </ div >
108+ < input ref = { input } hidden type = "file" accept = ".xlsx,.csv" onChange = { ( event ) => void choose ( event . target . files ?. [ 0 ] ) } />
109+ < div className = "import-contract" > < FileSpreadsheet /> < div > < b > Dos formatos nativos</ b > < p > Planilla detallada Pescamar por guía/lote, o snapshot resumido por planta. El sistema detecta cuál corresponde.</ p > </ div > </ div >
110+ < a className = "button secondary import-download" download = "plantilla_pescamar.csv" href = { `data:text/csv;charset=utf-8,${ encodeURIComponent ( sample ) } ` } > < Download size = { 15 } /> Descargar plantilla de snapshot</ a >
111+ { message ? < p className = "import-message" > < AlertCircle /> { message } </ p > : null }
112+ </ >
113+ ) : historical ? (
114+ < >
115+ < div className = "import-file" > < FileSpreadsheet /> < span > < b > { historical . fileName } </ b > < small > Formato nativo · hoja “{ historical . sheetName } ” · hash { historical . fileHash . slice ( 0 , 12 ) } …</ small > </ span > < button className = "button secondary" onClick = { reset } > Cambiar archivo</ button > </ div >
116+ < div className = "inspection-metrics" >
117+ < span > Registros< b > { historical . rows . length } </ b > </ span > < span > Operacionales< b > { historical . operational } </ b > </ span > < span > Anulados< b > { historical . voids } </ b > </ span > < span > Con observaciones< b > { historical . flagged } </ b > </ span >
115118 </ div >
116- < a
117- className = "button secondary import-download"
118- download = "plantilla_pescamar.csv"
119- href = { `data:text/csv;charset=utf-8,${ encodeURIComponent ( sample ) } ` }
120- >
121- < Download size = { 15 } />
122- Descargar plantilla de ejemplo
123- </ a >
124- { message ? (
125- < p className = "import-message" >
126- < AlertCircle />
127- { message }
128- </ p >
129- ) : null }
119+ < div className = "import-table-wrap" > < table className = "import-table" > < thead > < tr > < th > Fila</ th > < th > Guía</ th > < th > Proveedor</ th > < th > Lote</ th > < th > Kilos guía</ th > < th > Kilos recibidos</ th > < th > Resultado</ th > </ tr > </ thead > < tbody > { historical . rows . slice ( 0 , 120 ) . map ( row => < tr key = { row . sourceRow } className = { row . dataQualityFlags . length ?'has-error' :'' } > < td > { row . sourceRow } </ td > < td > { row . guideNumber ?? '—' } </ td > < td > { row . supplierName ?? '—' } </ td > < td > { row . lotCode } </ td > < td > { row . guideKg ?. toLocaleString ( 'es-CL' ) ?? '—' } </ td > < td > { row . receivedKg ?. toLocaleString ( 'es-CL' ) ?? '—' } </ td > < td > { row . recordStatus === 'void' ?< span className = "import-errors" > < AlertCircle /> Anulado</ span > :row . dataQualityFlags . length ?< span className = "import-errors" > < AlertCircle /> { row . dataQualityFlags . length } observación(es)</ span > :< span className = "import-ok" > < CheckCircle2 /> Lista</ span > } </ td > </ tr > ) } </ tbody > </ table > </ div >
120+ { historical . rows . length > 120 ?< small className = "disabled-note" > Vista previa de 120 filas de { historical . rows . length } . Todas serán procesadas.</ small > :null }
121+ { message ?< p className = "import-message" role = "alert" > < AlertCircle /> { message } </ p > :null }
122+ < footer > < button className = "button secondary" onClick = { close } > Cancelar</ button > < button className = "button primary" disabled = { busy || ! historical . rows . length } onClick = { ( ) => void publishHistorical ( ) } > { busy ?'Publicando…' :`Publicar ${ historical . rows . length } registros` } </ button > </ footer >
130123 </ >
131124 ) : (
132125 < >
133- < div className = "import-file" >
134- < FileSpreadsheet />
135- < span >
136- < b > { fileName } </ b >
137- < small >
138- { rows . length } filas · { valid . length } válidas · { invalid } con
139- error
140- </ small >
141- </ span >
142- < button className = "button secondary" onClick = { reset } >
143- Cambiar archivo
144- </ button >
145- </ div >
146- < div className = "import-table-wrap" >
147- < table className = "import-table" >
148- < thead >
149- < tr >
150- < th > Fila</ th >
151- < th > Planta</ th >
152- < th > Período</ th >
153- < th > Producción</ th >
154- < th > Meta</ th >
155- < th > Inventario</ th >
156- < th > Resultado</ th >
157- </ tr >
158- </ thead >
159- < tbody >
160- { rows . map ( ( row ) => (
161- < tr
162- key = { row . rowNumber }
163- className = { row . errors . length ? "has-error" : "" }
164- >
165- < td > { row . rowNumber } </ td >
166- < td > { row . plant ?. name ?? row . plantId } </ td >
167- < td > { row . period || "—" } </ td >
168- < td >
169- { Number . isFinite ( row . productionKg )
170- ? row . productionKg . toLocaleString ( "es-CL" )
171- : "—" }
172- </ td >
173- < td >
174- { Number . isFinite ( row . targetKg )
175- ? row . targetKg . toLocaleString ( "es-CL" )
176- : "—" }
177- </ td >
178- < td >
179- { Number . isFinite ( row . inventoryKg )
180- ? row . inventoryKg . toLocaleString ( "es-CL" )
181- : "—" }
182- </ td >
183- < td >
184- { row . errors . length ? (
185- < span className = "import-errors" >
186- < AlertCircle />
187- { row . errors . join ( " · " ) }
188- </ span >
189- ) : (
190- < span className = "import-ok" >
191- < CheckCircle2 />
192- Lista para publicar
193- </ span >
194- ) }
195- </ td >
196- </ tr >
197- ) ) }
198- </ tbody >
199- </ table >
200- </ div >
201- { message ? (
202- < p className = "import-message" role = "alert" >
203- < AlertCircle />
204- { message }
205- </ p >
206- ) : null }
207- < footer >
208- < button className = "button secondary" onClick = { close } >
209- Cancelar
210- </ button >
211- < button
212- className = "button primary"
213- disabled = { busy || ! valid . length || invalid > 0 }
214- onClick = { ( ) => void publish ( ) }
215- >
216- { busy ? "Publicando…" : `Publicar ${ valid . length } plantas` }
217- </ button >
218- </ footer >
126+ < div className = "import-file" > < FileSpreadsheet /> < span > < b > { fileName } </ b > < small > { rows . length } filas · { valid . length } válidas · { invalid } con error</ small > </ span > < button className = "button secondary" onClick = { reset } > Cambiar archivo</ button > </ div >
127+ < div className = "import-table-wrap" > < table className = "import-table" > < thead > < tr > < th > Fila</ th > < th > Planta</ th > < th > Período</ th > < th > Producción</ th > < th > Meta</ th > < th > Inventario</ th > < th > Resultado</ th > </ tr > </ thead > < tbody > { rows . map ( ( row ) => < tr key = { row . rowNumber } className = { row . errors . length ? "has-error" : "" } > < td > { row . rowNumber } </ td > < td > { row . plant ?. name ?? row . plantId } </ td > < td > { row . period || "—" } </ td > < td > { Number . isFinite ( row . productionKg ) ?row . productionKg . toLocaleString ( "es-CL" ) :"—" } </ td > < td > { Number . isFinite ( row . targetKg ) ?row . targetKg . toLocaleString ( "es-CL" ) :"—" } </ td > < td > { Number . isFinite ( row . inventoryKg ) ?row . inventoryKg . toLocaleString ( "es-CL" ) :"—" } </ td > < td > { row . errors . length ?< span className = "import-errors" > < AlertCircle /> { row . errors . join ( " · " ) } </ span > :< span className = "import-ok" > < CheckCircle2 /> Lista para publicar</ span > } </ td > </ tr > ) } </ tbody > </ table > </ div >
128+ { message ? < p className = "import-message" role = "alert" > < AlertCircle /> { message } </ p > : null }
129+ < footer > < button className = "button secondary" onClick = { close } > Cancelar</ button > < button className = "button primary" disabled = { busy || ! valid . length || invalid > 0 } onClick = { ( ) => void publish ( ) } > { busy ? "Publicando…" : `Publicar ${ valid . length } plantas` } </ button > </ footer >
219130 </ >
220131 ) }
221132 </ section >
0 commit comments