JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useState, useEffect, useMemo, useCallback } from "react";4import { Loader2, Download, Table2, ArrowUpDown, Save, Edit3 } from "lucide-react";5import type { PreviewPluginProps } from "./types";67// Column index → Excel-style letter (0 → A, 25 → Z, 26 → AA …)8function colLetter(n: number): string {9 let result = "";10 let idx = n;11 do {12 result = String.fromCharCode(65 + (idx % 26)) + result;13 idx = Math.floor(idx / 26) - 1;14 } while (idx >= 0);15 return result;16}1718interface SheetData {19 name: string;20 rows: (string | number | boolean | null)[][];21 colCount: number;22}2324interface SortConfig {25 col: number;26 dir: "asc" | "desc";27}2829const PAGE_SIZE = 100;3031export default function ExcelPreview({ file, isEditing, onSave }: PreviewPluginProps) {32 const [sheets, setSheets] = useState<SheetData[]>([]);33 const [activeSheet, setActiveSheet] = useState(0);34 const [loading, setLoading] = useState(true);35 const [error, setError] = useState(false);36 const [saving, setSaving] = useState(false);37 // editData mirrors sheets data for in-memory editing38 const [editData, setEditData] = useState<(string | number | null)[][][]>([]);3940 useEffect(() => {41 let cancelled = false;4243 const parse = async () => {44 setLoading(true);45 setError(false);46 try {47 const res = await fetch(`/api/files/${file.id}/download`);48 if (!res.ok) throw new Error("Download failed");49 const arrayBuffer = await res.arrayBuffer();5051 const XLSX = await import("xlsx");52 const workbook = XLSX.read(new Uint8Array(arrayBuffer), { type: "array", cellDates: true });5354 const parsed: SheetData[] = workbook.SheetNames.map((name) => {55 const ws = workbook.Sheets[name];56 const raw = XLSX.utils.sheet_to_json<(string | number | boolean | null)[]>(ws, {57 header: 1,58 defval: null,59 blankrows: true,60 });61 const colCount = raw.reduce((max, row) => Math.max(max, row.length), 0);62 return { name, rows: raw as (string | number | boolean | null)[][], colCount };63 });6465 if (!cancelled) {66 setSheets(parsed);67 setActiveSheet(0);68 // Init edit data as string copies69 setEditData(parsed.map((s) => s.rows.map((row) => row.map((c) => (c === null ? null : String(c))))));70 }71 } catch {72 if (!cancelled) setError(true);73 } finally {74 if (!cancelled) setLoading(false);75 }76 };7778 parse();79 return () => { cancelled = true; };80 }, [file.id]);8182 const handleCellChange = useCallback((sheetIdx: number, rowIdx: number, colIdx: number, value: string) => {83 setEditData((prev) => {84 const next = prev.map((s) => s.map((r) => [...r]));85 // Ensure nested arrays exist86 while (next[sheetIdx].length <= rowIdx) next[sheetIdx].push([]);87 while (next[sheetIdx][rowIdx].length <= colIdx) next[sheetIdx][rowIdx].push(null);88 next[sheetIdx][rowIdx][colIdx] = value;89 return next;90 });91 }, []);9293 const handleSave = useCallback(async () => {94 if (!onSave) return;95 setSaving(true);96 try {97 const XLSX = await import("xlsx");98 const wb = XLSX.utils.book_new();99 editData.forEach((sheetRows, si) => {100 const ws = XLSX.utils.aoa_to_sheet(sheetRows.map((row) =>101 row.map((c) => {102 if (c === null || c === "") return null;103 const num = Number(c);104 return isNaN(num) ? c : num;105 })106 ));107 XLSX.utils.book_append_sheet(wb, ws, sheets[si]?.name || `Sheet${si + 1}`);108 });109 const array = XLSX.write(wb, { bookType: "xlsx", type: "array" });110 const blob = new Blob([array], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });111 await onSave(blob, true);112 } finally {113 setSaving(false);114 }115 }, [editData, sheets, onSave]);116117 if (loading) {118 return (119 <div className="flex flex-col items-center justify-center h-full gap-3 text-gray-400 bg-white">120 <Loader2 className="w-8 h-8 animate-spin text-[#217346]" />121 <p className="text-sm text-gray-500">Chargement du classeur Excel…</p>122 </div>123 );124 }125126 if (error || sheets.length === 0) {127 return (128 <div className="flex flex-col items-center justify-center h-full gap-4 text-gray-400 p-8 bg-white">129 <Table2 className="w-16 h-16 text-[#217346] opacity-50" />130 <p className="text-sm text-center text-gray-500">Impossible d'afficher ce fichier Excel.</p>131 <button132 onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")}133 className="flex items-center gap-2 px-4 py-2 bg-[#217346] hover:bg-[#1a5c38] rounded text-sm text-white transition-colors"134 >135 <Download className="w-4 h-4" />136 Télécharger137 </button>138 </div>139 );140 }141142 const currentSheet = sheets[activeSheet];143 const currentEditRows = editData[activeSheet] ?? currentSheet.rows.map((r) => r.map((c) => (c === null ? null : String(c))));144145 return (146 <div className="flex flex-col h-full bg-white text-gray-900">147 {/* Toolbar */}148 <div className="flex items-center gap-2 px-3 py-1.5 bg-[#f2f2f2] border-b border-[#d0d7de] shrink-0">149 <span className="text-xs text-gray-500 font-medium">{file.name}</span>150 <div className="flex-1" />151 {isEditing && (152 <button153 onClick={handleSave}154 disabled={saving}155 className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[#217346] hover:bg-[#1a5c38] text-white rounded transition-colors disabled:opacity-60"156 >157 {saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}158 Sauvegarder159 </button>160 )}161 {!isEditing && (162 <span className="text-xs text-gray-400 flex items-center gap-1">163 <Edit3 className="w-3.5 h-3.5" />164 Mode lecture165 </span>166 )}167 <button168 onClick={() => window.open(`/api/files/${file.id}/download`, "_blank")}169 className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-[#217346] transition-colors"170 >171 <Download className="w-3.5 h-3.5" />172 </button>173 </div>174175 {/* Sheet tabs — Excel style */}176 <div className="flex items-end gap-px px-2 pt-1 bg-[#f2f2f2] border-b border-[#d0d7de] overflow-x-auto shrink-0">177 {sheets.map((sheet, i) => (178 <button179 key={sheet.name}180 onClick={() => setActiveSheet(i)}181 className={`px-4 py-1.5 text-xs rounded-t transition-colors whitespace-nowrap border border-b-0 ${182 i === activeSheet183 ? "bg-[#217346] text-white border-[#217346]"184 : "bg-[#e0e0e0] text-gray-600 border-[#c0c0c0] hover:bg-[#d0d0d0]"185 }`}186 >187 {sheet.name}188 </button>189 ))}190 <div className="ml-auto flex items-center gap-2 pb-1">191 <span className="text-[10px] text-gray-500 whitespace-nowrap">192 {currentSheet.rows.length} lignes · {currentSheet.colCount} col.193 </span>194 </div>195 </div>196197 {/* Sheet viewer */}198 <SheetViewer199 sheet={currentSheet}200 editRows={currentEditRows}201 sheetIdx={activeSheet}202 isEditing={isEditing}203 onCellChange={handleCellChange}204 key={activeSheet}205 />206 </div>207 );208}209210function SheetViewer({211 sheet,212 editRows,213 sheetIdx,214 isEditing,215 onCellChange,216}: {217 sheet: SheetData;218 editRows: (string | number | null)[][];219 sheetIdx: number;220 isEditing: boolean;221 onCellChange: (si: number, ri: number, ci: number, v: string) => void;222}) {223 const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);224 const [page, setPage] = useState(0);225226 const headerRow = editRows[0] ?? [];227 const dataRows = editRows.slice(1);228 const colCount = sheet.colCount;229230 const sortedRows = useMemo(() => {231 if (!sortConfig || isEditing) return dataRows;232 return [...dataRows].sort((a, b) => {233 const aVal = a[sortConfig.col] ?? "";234 const bVal = b[sortConfig.col] ?? "";235 const numA = parseFloat(String(aVal));236 const numB = parseFloat(String(bVal));237 if (!isNaN(numA) && !isNaN(numB)) {238 return sortConfig.dir === "asc" ? numA - numB : numB - numA;239 }240 return sortConfig.dir === "asc"241 ? String(aVal).localeCompare(String(bVal))242 : String(bVal).localeCompare(String(aVal));243 });244 }, [dataRows, sortConfig, isEditing]);245246 const pageRows = sortedRows.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);247 const totalPages = Math.ceil(sortedRows.length / PAGE_SIZE);248249 const toggleSort = useCallback((col: number) => {250 if (isEditing) return;251 setSortConfig((prev) => {252 if (prev?.col === col) return prev.dir === "asc" ? { col, dir: "desc" } : null;253 return { col, dir: "asc" };254 });255 setPage(0);256 }, [isEditing]);257258 const formatCell = (val: string | number | null | undefined): string => {259 if (val === null || val === undefined) return "";260 return String(val);261 };262263 const isNumeric = (val: string | number | null | undefined): boolean =>264 typeof val === "number" || (!isNaN(parseFloat(String(val))) && val !== null && val !== "");265266 return (267 <div className="flex flex-col flex-1 overflow-hidden bg-white">268 <div className="flex-1 overflow-auto">269 <table className="text-xs border-collapse min-w-max">270 {/* Column letter row */}271 <thead className="sticky top-0 z-10">272 <tr>273 {/* Row number gutter header */}274 <th className="w-10 min-w-[2.5rem] border border-[#d0d7de] px-2 py-1 bg-[#f2f2f2] text-gray-500 font-normal select-none" />275 {Array.from({ length: colCount }, (_, i) => (276 <th277 key={i}278 className="border border-[#d0d7de] px-1 py-1 bg-[#f2f2f2] text-gray-500 font-normal text-center min-w-[80px]"279 >280 {colLetter(i)}281 </th>282 ))}283 </tr>284285 {/* Header data row */}286 <tr>287 <td className="border border-[#d0d7de] px-2 py-1 bg-[#f2f2f2] text-gray-400 text-center font-normal select-none">288 1289 </td>290 {Array.from({ length: colCount }, (_, i) => {291 const val = headerRow[i] ?? null;292 const sorted = sortConfig?.col === i;293 return (294 <th295 key={i}296 className="border border-[#d0d7de] bg-[#f2f2f2] text-gray-700 font-semibold text-left p-0"297 >298 {isEditing ? (299 <input300 type="text"301 value={formatCell(val)}302 onChange={(e) => onCellChange(sheetIdx, 0, i, e.target.value)}303 className="w-full px-2 py-1 bg-white border-0 outline-none focus:ring-2 focus:ring-[#217346] focus:ring-inset text-gray-900 font-semibold"304 />305 ) : (306 <button307 onClick={() => toggleSort(i)}308 className="flex items-center gap-1 w-full px-2 py-1 hover:bg-[#e8f0e8] transition-colors"309 >310 <span className="truncate max-w-[120px]">{formatCell(val)}</span>311 <ArrowUpDown className={`w-3 h-3 shrink-0 ${sorted ? "text-[#217346]" : "text-gray-400"}`} />312 </button>313 )}314 </th>315 );316 })}317 </tr>318 </thead>319320 <tbody>321 {pageRows.map((row, rowIdx) => {322 const actualRowIdx = page * PAGE_SIZE + rowIdx + 1; // 0-based in editRows (1 = first data row)323 return (324 <tr325 key={rowIdx}326 className={rowIdx % 2 === 0 ? "bg-white" : "bg-[#f9f9f9]"}327 >328 {/* Row number */}329 <td className="border border-[#e0e0e0] px-2 py-0.5 text-gray-400 text-center bg-[#f2f2f2] select-none">330 {actualRowIdx + 1}331 </td>332 {Array.from({ length: colCount }, (_, colIdx) => {333 const val = row[colIdx] ?? null;334 const numeric = isNumeric(val);335 return (336 <td337 key={colIdx}338 className={`border border-[#e0e0e0] p-0 ${!isEditing && numeric ? "text-right" : ""}`}339 >340 {isEditing ? (341 <input342 type="text"343 value={formatCell(val)}344 onChange={(e) => onCellChange(sheetIdx, actualRowIdx, colIdx, e.target.value)}345 className="w-full min-w-[80px] px-2 py-0.5 bg-transparent border-0 outline-none focus:ring-2 focus:ring-[#217346] focus:ring-inset text-gray-900"346 />347 ) : (348 <span className={`block px-2 py-0.5 ${numeric ? "text-[#1155cc] text-right" : "text-gray-800"}`}>349 {formatCell(val)}350 </span>351 )}352 </td>353 );354 })}355 </tr>356 );357 })}358359 {pageRows.length === 0 && (360 <tr>361 <td colSpan={colCount + 1} className="border border-[#e0e0e0] px-4 py-8 text-center text-gray-400">362 Feuille vide363 </td>364 </tr>365 )}366 </tbody>367 </table>368 </div>369370 {/* Pagination */}371 {totalPages > 1 && (372 <div className="flex items-center justify-between px-3 py-2 border-t border-[#d0d7de] bg-[#f2f2f2] shrink-0">373 <button374 onClick={() => setPage((p) => Math.max(0, p - 1))}375 disabled={page === 0}376 className="px-2 py-1 text-xs text-gray-500 hover:text-[#217346] disabled:opacity-30 transition-colors"377 >378 ← Précédent379 </button>380 <span className="text-xs text-gray-500">381 Lignes {page * PAGE_SIZE + 2}–{Math.min((page + 1) * PAGE_SIZE + 1, sortedRows.length + 1)} sur {sortedRows.length + 1}382 </span>383 <button384 onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}385 disabled={page >= totalPages - 1}386 className="px-2 py-1 text-xs text-gray-500 hover:text-[#217346] disabled:opacity-30 transition-colors"387 >388 Suivant →389 </button>390 </div>391 )}392 </div>393 );394}395