diff --git a/src/taskpane/components/App.tsx b/src/taskpane/components/App.tsx index 751acef..ffc2c74 100644 --- a/src/taskpane/components/App.tsx +++ b/src/taskpane/components/App.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import { useState, useEffect } from "react"; -import { makeStyles } from "@fluentui/react-components"; +import { makeStyles, TabList, Tab } from "@fluentui/react-components"; import { SheetInfo, SheetMappingStatus, ConsolidateSettings } from "../models"; import SheetSelector from "./SheetSelector"; import ColumnMapper from "./ColumnMapper"; import StatusNotifier from "./StatusNotifier"; +import CompareWizard from "./CompareWizard"; import { getAvailableSheets, detectHeadersAndColumns, detectHeadersForSingleSheetRow, consolidateData } from "../excelLogic"; interface AppProps { @@ -24,6 +25,9 @@ const useStyles = makeStyles({ const App: React.FC = () => { const styles = useStyles(); + type AppMode = "consolidate" | "compare"; + const [appMode, setAppMode] = useState("consolidate"); + type WizardStep = "select_sheets" | "map_columns" | "done"; const [step, setStep] = useState("select_sheets"); @@ -70,24 +74,24 @@ const App: React.FC = () => { } }; - const handleHeaderRowChange = async (sheetName: string, newRowIndex: number) => { + const handleHeaderRowChange = async (sheetId: string, newRowIndex: number) => { try { // Wir müssen das richtige SheetInfo Objekt finden (für isExternal check) - const sheetInfo = sheets.find(s => s.name === sheetName); + const sheetInfo = sheets.find(s => (s.id || s.name) === sheetId); if (!sheetInfo) return; // Re-detect columns for exactly this row const newMapping = await detectHeadersForSingleSheetRow(sheetInfo, newRowIndex); - setSheetMappings(prev => prev.map(m => m.sheetName === sheetName ? newMapping : m)); + setSheetMappings(prev => prev.map(m => (m.sheetId || m.sheetName) === sheetId ? newMapping : m)); } catch (err) { setStatus("error"); - setStatusMessage("Fehler beim Neuladen der Zeile " + newRowIndex + " für Blatt " + sheetName); + setStatusMessage("Fehler beim Neuladen der Zeile " + newRowIndex + " für Blatt " + sheetId); } }; - const handleMappingChange = (sheetName: string, targetCol: string, sourceColIndex: number) => { + const handleMappingChange = (sheetId: string, targetCol: string, sourceColIndex: number) => { setSheetMappings(prev => prev.map(sheet => { - if (sheet.sheetName !== sheetName) return sheet; + if ((sheet.sheetId || sheet.sheetName) !== sheetId) return sheet; const newMappings = sheet.mappings.map(m => m.targetColumn === targetCol ? { ...m, sourceColumnIndex: sourceColIndex } : m @@ -119,48 +123,71 @@ const App: React.FC = () => { DEV ENVIRONMENT )} + +
+ setAppMode(data.value as AppMode)} + > + Zusammenfassen + Vergleichen + +
+ - {step === "select_sheets" && ( - + {step === "select_sheets" && ( + + )} + + {step === "map_columns" && ( + setStep("select_sheets")} + onConsolidate={handleConsolidate} + isConsolidating={isConsolidating} + /> + )} + + {step === "done" && ( +
+

Fertig!

+

Die Daten wurden in die 'Gesamtliste' geschrieben.

+ +
+ )} + + )} + + {appMode === "compare" && ( + )} - {step === "map_columns" && ( - setStep("select_sheets")} - onConsolidate={handleConsolidate} - isConsolidating={isConsolidating} - /> - )} - - {step === "done" && ( -
-

Fertig!

-

Die Daten wurden in die 'Gesamtliste' geschrieben.

- -
- )} - {/* Footer Area with Links and Copyright */}
diff --git a/src/taskpane/components/ColumnMapper.tsx b/src/taskpane/components/ColumnMapper.tsx index e458715..6258ff6 100644 --- a/src/taskpane/components/ColumnMapper.tsx +++ b/src/taskpane/components/ColumnMapper.tsx @@ -17,13 +17,17 @@ import { SheetMappingStatus, TARGET_COLUMNS, ConsolidateSettings } from "../mode interface ColumnMapperProps { sheetMappings: SheetMappingStatus[]; - onHeaderRowChange: (sheetName: string, newRowIndex: number) => void; - onMappingChange: (sheetName: string, targetCol: string, sourceColIndex: number) => void; + onHeaderRowChange: (sheetIdentifier: string, newRowIndex: number) => void; + onMappingChange: (sheetIdentifier: string, targetCol: string, sourceColIndex: number) => void; onBack: () => void; onConsolidate: () => void; isConsolidating: boolean; settings: ConsolidateSettings; onSettingsChange: (settings: ConsolidateSettings) => void; + title?: string; + description?: string; + actionLabel?: string; + actionLoadingLabel?: string; } const ColumnMapper: React.FC = ({ @@ -35,22 +39,27 @@ const ColumnMapper: React.FC = ({ isConsolidating, settings, onSettingsChange, + title = "2. Spalten-Mapping prüfen", + description = "Bitte überprüfe die gefundenen Kopfzeilen und passe fehlende Spalten manuell an.", + actionLabel = "Konsolidieren", + actionLoadingLabel = "Konsolidierung läuft...", }) => { return (
- 2. Spalten-Mapping prüfen + {title} - Bitte überprüfe die gefundenen Kopfzeilen und passe fehlende Spalten manuell an. + {description} - s.sheetName)}> + s.sheetId || s.sheetName)}> {sheetMappings.map((sheet) => { + const identifier = sheet.sheetId || sheet.sheetName; const missingCount = sheet.mappings.filter((m) => m.sourceColumnIndex === -1).length; return ( - +
{sheet.sheetName} @@ -79,7 +88,7 @@ const ColumnMapper: React.FC = ({ min={0} onChange={(_, data) => { if (data.value !== undefined) { - onHeaderRowChange(sheet.sheetName, data.value); + onHeaderRowChange(identifier, data.value); } }} style={{ width: "80px" }} @@ -102,7 +111,7 @@ const ColumnMapper: React.FC = ({ selectedOptions={[selectedVal || "-1"]} onOptionSelect={(_, data) => { const newIndex = parseInt(data.optionValue || "-1", 10); - onMappingChange(sheet.sheetName, colName, newIndex); + onMappingChange(identifier, colName, newIndex); }} style={{ minWidth: "150px" }} > @@ -169,7 +178,7 @@ const ColumnMapper: React.FC = ({ disabled={isConsolidating || sheetMappings.length === 0} icon={isConsolidating ? : undefined} > - {isConsolidating ? "Konsolidierung läuft..." : "Konsolidieren"} + {isConsolidating ? actionLoadingLabel : actionLabel}
diff --git a/src/taskpane/components/CompareWizard.tsx b/src/taskpane/components/CompareWizard.tsx new file mode 100644 index 0000000..d1d5a3c --- /dev/null +++ b/src/taskpane/components/CompareWizard.tsx @@ -0,0 +1,221 @@ +import * as React from "react"; +import { useState } from "react"; +import { makeStyles, Button, Dropdown, Option, Label, Card, CardHeader } from "@fluentui/react-components"; +import { SheetInfo, ConsolidateSettings, SheetMappingStatus } from "../models"; +import { detectHeadersAndColumns, compareData } from "../excelLogic"; +import * as XLSX from "xlsx"; +import ColumnMapper from "./ColumnMapper"; + +interface CompareWizardProps { + availableSheets: SheetInfo[]; + onExternalSheetsLoaded: (sheets: SheetInfo[]) => void; + globalSettings: ConsolidateSettings; +} + +const useStyles = makeStyles({ + root: { + padding: "10px", + display: "flex", + flexDirection: "column", + gap: "15px", + }, + card: { + maxWidth: "400px", + }, + fileInput: { + display: "none", + }, +}); + +type CompareStep = "select_lists" | "map_columns" | "done"; + +const CompareWizard: React.FC = ({ availableSheets, onExternalSheetsLoaded, globalSettings }) => { + const styles = useStyles(); + + const [step, setStep] = useState("select_lists"); + + const [oldSheetId, setOldSheetId] = useState(""); + const [newSheetId, setNewSheetId] = useState(""); + + const [sheetMappings, setSheetMappings] = useState([]); + const [isComparing, setIsComparing] = useState(false); + + const handleFileUpload = async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const reader = new FileReader(); + + reader.onload = (e) => { + const data = e.target?.result; + if (data) { + const workbook = XLSX.read(data, { type: "binary" }); + const newSheets: SheetInfo[] = []; + + workbook.SheetNames.forEach((sheetName) => { + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + newSheets.push({ + id: `ext_${Date.now()}_${sheetName}`, + name: sheetName, + isExternal: true, + fileName: file.name, + externalData: jsonData, + }); + }); + onExternalSheetsLoaded(newSheets); + } + }; + reader.readAsBinaryString(file); + } + }; + + const handleNextToMapping = async () => { + if (!oldSheetId || !newSheetId) return; + + const oldSheet = availableSheets.find(s => s.id === oldSheetId); + const newSheet = availableSheets.find(s => s.id === newSheetId); + + if (!oldSheet || !newSheet) return; + + try { + // detectHeadersAndColumns needs an array of sheets and returns mappings for them + const mappings = await detectHeadersAndColumns([oldSheet, newSheet]); + setSheetMappings(mappings); + setStep("map_columns"); + } catch (err) { + console.error(err); + alert("Fehler beim Erkennen der Spalten."); + } + }; + + const handleExecuteComparison = async () => { + setIsComparing(true); + try { + const oldSheet = availableSheets.find(s => s.id === oldSheetId); + const newSheet = availableSheets.find(s => s.id === newSheetId); + + console.log("oldSheetId:", oldSheetId, "newSheetId:", newSheetId); + console.log("Mappings vorhanden für:", sheetMappings.map(m => m.sheetId || m.sheetName)); + + // Suche mapping über id + const oldMapping = sheetMappings.find(m => (m.sheetId || m.sheetName) === oldSheetId); + const newMapping = sheetMappings.find(m => (m.sheetId || m.sheetName) === newSheetId); + + if (!oldMapping || !newMapping) { + console.error("Old Mapping found:", !!oldMapping, "New Mapping found:", !!newMapping); + throw new Error(`Mappings für die ausgewählten Listen nicht gefunden. Erwartet: ${oldSheet?.name}, ${newSheet?.name}`); + } + + const rowsCompared = await compareData(oldMapping, newMapping, globalSettings); + alert(`Vergleich abgeschlossen! ${rowsCompared} Änderungen dokumentiert.`); + setStep("done"); + } catch (err: any) { + alert(err.message || "Fehler beim Vergleichen der Listen. Details in Console."); + } finally { + setIsComparing(false); + } + }; + + // Provide a safe way to select if multiple sheets with same name but different files exist + const renderOptions = () => { + return availableSheets.map(s => { + const label = s.isExternal ? `${s.name} (aus ${s.fileName})` : s.name; + return ; + }); + }; + + if (step === "map_columns") { + // Reuse ColumnMapper but pass specific logic (or hide settings for now) + return ( +
+ { }} // Not needed for comparing usually, or handled differently + onHeaderRowChange={() => { + // Re-detect logic here (similar to App.tsx) - will implement later if needed + }} + onMappingChange={(sheetId, targetCol, sourceColIndex) => { + const newMappings = [...sheetMappings]; + const sheetIdx = newMappings.findIndex(m => (m.sheetId || m.sheetName) === sheetId); + if (sheetIdx > -1) { + const mappingIdx = newMappings[sheetIdx].mappings.findIndex(m => m.targetColumn === targetCol); + if (mappingIdx > -1) { + newMappings[sheetIdx].mappings[mappingIdx].sourceColumnIndex = sourceColIndex; + setSheetMappings(newMappings); + } + } + }} + onBack={() => setStep("select_lists")} + onConsolidate={handleExecuteComparison} + isConsolidating={isComparing} + title="2. Spalten-Mapping für Vergleich prüfen" + description="Bitte überprüfe die gefundenen Kopfzeilen für alte und neue Liste." + actionLabel="Vergleichen" + actionLoadingLabel="Vergleich läuft..." + /> +
+ ); + } + + // default: select_lists + return ( +
+

Listen vergleichen

+

Lade Datei(en) hoch und wähle die alte und die neue Liste aus.

+ +
+ + +
+ + + 1. Alte Liste (Basis)} /> + setOldSheetId(data.optionValue as string)} + value={availableSheets.find(s => s.id === oldSheetId)?.name || ""} + > + {renderOptions()} + + + + + 2. Neue Liste (Aktueller Stand)} /> + setNewSheetId(data.optionValue as string)} + value={availableSheets.find(s => s.id === newSheetId)?.name || ""} + > + {renderOptions()} + + + + + +
+ ); +}; + +export default CompareWizard; diff --git a/src/taskpane/excelLogic.ts b/src/taskpane/excelLogic.ts index b8e66ff..a0f6791 100644 --- a/src/taskpane/excelLogic.ts +++ b/src/taskpane/excelLogic.ts @@ -115,6 +115,7 @@ function buildSheetMappingStatus(sheetInfo: SheetInfo, headerRow: any[], rowInde }); return { + sheetId: sheetInfo.id, sheetName: sheetInfo.name, // Für Anzeige headerRowIndex: rowIndex, availableColumns, @@ -424,3 +425,533 @@ export async function consolidateData(mappings: SheetMappingStatus[], settings: } }); } +/** + + * Neue Funktion für den Vergleich von zwei Listen + + */ + +export async function compareData( + + oldMapping: SheetMappingStatus, + + newMapping: SheetMappingStatus, + + settings: ConsolidateSettings + +): Promise { + + return Excel.run(async (context) => { + + // Hilfsfunktion zum Laden von Daten aus einem Mapping + + const loadData = async (mapping: SheetMappingStatus): Promise => { + + if (mapping.isExternal && mapping.externalData) { + + // Return data starting after the header row + + return mapping.externalData.slice(mapping.headerRowIndex + 1); + + } else { + + const sheet = context.workbook.worksheets.getItem(mapping.sheetName); + + const usedRange = sheet.getUsedRange(); + + usedRange.load(["rowIndex", "rowCount", "text"]); + + await context.sync(); + + + + const startRowIdx = usedRange.rowIndex; + + const dataStartRowOffset = (mapping.headerRowIndex + 1) - startRowIdx; + + + + if (dataStartRowOffset >= usedRange.rowCount || usedRange.rowCount === 0) { + + return []; + + } + + + + // Return data starting from the calculated offset + + return usedRange.text.slice(dataStartRowOffset); + + } + + }; + + + + const oldData = await loadData(oldMapping); + + const newData = await loadData(newMapping); + + + + // K-Nr Indices finden + + const getColIndex = (mapping: SheetMappingStatus, targetId: string) => { + + const m = mapping.mappings.find(m => m.targetColumn === targetId); + + return m ? m.sourceColumnIndex : -1; + + }; + + + + const oldKNrIdx = getColIndex(oldMapping, "K-Nr."); + + const newKNrIdx = getColIndex(newMapping, "K-Nr."); + + + + if (oldKNrIdx === -1 || newKNrIdx === -1) { + + throw new Error("K-Nr. Spalte muss in beiden Listen zugeordnet sein, um vergleichen zu können."); + + } + + + + // Zu vergleichende Spalten (ohne die Ausnahmen) + + const columnsToCompare = TARGET_COLUMNS.filter( + + tc => tc.id !== "K-Nr." && tc.id !== "Länge" && tc.id !== "gezogen am" && tc.id !== "von" // 'von' hier ist Monteur in Gesamtliste, aber TARGET_COLUMNS['von'] ist Start! + + ); + + // Wir vergleichen ALLE gemappten Target-Columns, aber ignorieren (wie gefordert) Lnge/gezogen am/gezogen von, + + // welche gar nicht in TARGET_COLUMNS sind! "von" und "nach" in TARGET_COLUMNS sind die Räume/Geräte. + + // Das heißt wir ignorieren hier nichts extra aus TARGET_COLUMNS, da die spezifischen "gezogen am" etc Felder + + // erst in der Consolidate Funktion hardcoded angehängt werden und gar nicht gemappt sind! + + const allTargetCols = TARGET_COLUMNS.map(tc => tc.id); + + + + // "von Raum" und "nach Raum" Indizes für Umverlegt-Check + + const vonRaumId = "von Raum"; + + const nachRaumId = "nach Raum"; + + + + // Mapped Header für die Ausgabetabelle + + const outputHeaders = ["K-Nr.", "Status", "Änderungsdetails", ...allTargetCols.filter(id => id !== "K-Nr.")]; + + + + // Maps erstellen + + const oldMap = new Map(); + + const oldDuplicates = new Set(); + + + + for (const row of oldData) { + + const knr = String(row[oldKNrIdx] || "").trim(); + + if (!knr) continue; + + if (oldMap.has(knr)) { + + oldDuplicates.add(knr); + + } + + oldMap.set(knr, row); + + } + + + + const newMap = new Map(); + + const newDuplicates = new Set(); + + + + for (const row of newData) { + + const knr = String(row[newKNrIdx] || "").trim(); + + if (!knr) continue; + + if (newMap.has(knr)) { + + newDuplicates.add(knr); + + } + + newMap.set(knr, row); + + } + + + + const finalOutput: any[][] = []; + + const formatQueue: { row: number, state: "added" | "removed" | "changed" | "moved", formatColRanges?: number[] }[] = []; + + let currentRowIndex = 1; // 1-based, header is 0 + + + + // 1. Check old list against new list (Removed / Changed / Moved) + + oldMap.forEach((oldRow, knr) => { + + if (!newMap.has(knr)) { + + // Entfernt + + const outRow = [knr, "Entfernt", "Kabel fehlt in der neuen Liste"]; + + for (const colId of allTargetCols) { + + if (colId === "K-Nr.") continue; + + const idx = getColIndex(oldMapping, colId); + + outRow.push(idx !== -1 ? oldRow[idx] : ""); + + } + + + + if (oldDuplicates.has(knr)) outRow[2] += " (Duplikat in alter Liste)"; + + + + finalOutput.push(outRow); + + formatQueue.push({ row: currentRowIndex, state: "removed" }); + + currentRowIndex++; + + } else { + + // Existiert in beiden -> Vergleichen + + const newRow = newMap.get(knr)!; + + let isChanged = false; + + let isMoved = false; + + const changes: string[] = []; + + const changedCols: number[] = []; + + + + for (const colId of allTargetCols) { + + if (colId === "K-Nr.") continue; + + + + const oIdx = getColIndex(oldMapping, colId); + + const nIdx = getColIndex(newMapping, colId); + + + + const oVal = oIdx !== -1 ? String(oldRow[oIdx] || "").trim() : ""; + + const nVal = nIdx !== -1 ? String(newRow[nIdx] || "").trim() : ""; + + + + if (oVal !== nVal) { + + isChanged = true; + + changes.push(`${colId}: ${oVal || "Leer"} -> ${nVal || "Leer"}`); + + + + // Output Header mapping: 0=KNr, 1=Status, 2=Details, 3... = the rest (offset by 3) + + const outputColIdx = outputHeaders.indexOf(colId); + + if (outputColIdx !== -1) { + + changedCols.push(outputColIdx); + + } + + + + if (colId === vonRaumId || colId === nachRaumId) { + + isMoved = true; + + } + + } + + } + + + + if (isChanged) { + + const status = isMoved ? "Umverlegt" : "Geändert"; + + let details = changes.join(" | "); + + + + if (oldDuplicates.has(knr) || newDuplicates.has(knr)) { + + details += " (Duplikat gefunden!)"; + + } + + + + const outRow = [knr, status, details]; + + for (const colId of allTargetCols) { + + if (colId === "K-Nr.") continue; + + const idx = getColIndex(newMapping, colId); // Zeigen die NEUEN Werte! + + outRow.push(idx !== -1 ? newRow[idx] : ""); + + } + + finalOutput.push(outRow); + + formatQueue.push({ row: currentRowIndex, state: isMoved ? "moved" : "changed", formatColRanges: changedCols }); + + currentRowIndex++; + + } + + } + + }); + + + + // 2. Check new list against old list (Added) + + newMap.forEach((newRow, knr) => { + + if (!oldMap.has(knr)) { + + // Hinzugefügt + + const outRow = [knr, "Neu", "Kabel neu hinzugefügt"]; + + for (const colId of allTargetCols) { + + if (colId === "K-Nr.") continue; + + const idx = getColIndex(newMapping, colId); + + outRow.push(idx !== -1 ? newRow[idx] : ""); + + } + + + + if (newDuplicates.has(knr)) outRow[2] += " (Duplikat in neuer Liste)"; + + + + finalOutput.push(outRow); + + formatQueue.push({ row: currentRowIndex, state: "added" }); + + currentRowIndex++; + + } + + }); + + + + // 3. Wenn keine Änderungen + + if (finalOutput.length === 0) { + + return 0; + + } + + + + // 4. In "Änderungsdokumentation" schreiben + + const sheetName = "Änderungsdokumentation"; + + let targetSheet: Excel.Worksheet; + + + + try { + + targetSheet = context.workbook.worksheets.getItem(sheetName); + + targetSheet.delete(); // Delete if exists to create a fresh one + + await context.sync(); + + } catch (e) { + + // Does not exist + + } + + + + targetSheet = context.workbook.worksheets.add(sheetName); + + + + const totalRowsCount = finalOutput.length + 1; // +1 für Header + + const totalColsCount = outputHeaders.length; + + + + const targetRange = targetSheet.getRangeByIndexes(0, 0, totalRowsCount, totalColsCount); + + const allValues = [outputHeaders, ...finalOutput]; + + + + const formatArray: string[][] = []; + + for (let i = 0; i < totalRowsCount; i++) { + + formatArray.push(new Array(totalColsCount).fill("@")); + + } + + + + targetRange.numberFormat = formatArray; + + targetRange.values = allValues; + + + + await context.sync(); + + + + const table = targetSheet.tables.add(targetRange, true /* hasHeaders */); + + table.style = "TableStyleLight9"; + + table.showFilterButton = true; + + + + await context.sync(); + + const bodyRange = table.getDataBodyRange(); + + + + // 5. Formate anwenden + + for (const fmt of formatQueue) { + + // row is 1-based (from data), bodyRange getRow is 0-based + + const excelRow = bodyRange.getRow(fmt.row - 1); + + + + if (fmt.state === "removed") { + + excelRow.format.fill.color = settings.colorDeleted; + + excelRow.format.font.strikethrough = true; + + excelRow.format.font.color = "#990000"; // Dunkelrot + + } else if (fmt.state === "added") { + + excelRow.format.fill.color = settings.colorNew; + + } else if (fmt.state === "moved") { + + // Umverlegt z.B. hellblau oder lila (wir nehmen ein vordefiniertes Gelb oder passen settings an, hardcoded für jetzt) + + excelRow.format.fill.color = "#d9edf7"; // Light Blue + + + + // Einzelne Zellen markieren + + if (fmt.formatColRanges) { + + for (const col of fmt.formatColRanges) { + + bodyRange.getCell(fmt.row - 1, col).format.fill.color = "#bce8f1"; // Stronger Blue + + } + + } + + } else if (fmt.state === "changed") { + + excelRow.format.fill.color = settings.colorChanged; + + + + // Einzelne Zellen markieren + + if (fmt.formatColRanges) { + + for (const col of fmt.formatColRanges) { + + bodyRange.getCell(fmt.row - 1, col).format.fill.color = "#ffe699"; // Stronger Yellow + + } + + } + + } + + } + + + + targetRange.format.autofitColumns(); + + await context.sync(); + + + + targetSheet.activate(); + + return finalOutput.length; + + }); + +} + diff --git a/src/taskpane/models.ts b/src/taskpane/models.ts index daca6a6..bb29f22 100644 --- a/src/taskpane/models.ts +++ b/src/taskpane/models.ts @@ -12,6 +12,7 @@ export interface ColumnMappingInfo { } export interface SheetMappingStatus { + sheetId?: string; // Eindeutige ID (z.B. für identisch benannte externe Blätter) sheetName: string; headerRowIndex: number; // 0-indexed mappings: ColumnMappingInfo[];