SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
14.7 KB · 395 lines tsx
Raw Blame History
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&apos;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