Initial Project

This commit is contained in:
Toni Martin
2026-02-25 13:16:14 +01:00
commit ca0022e250
73 changed files with 20166 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { makeStyles } from "@fluentui/react-components";
import { SheetInfo, SheetMappingStatus } from "../models";
import SheetSelector from "./SheetSelector";
import ColumnMapper from "./ColumnMapper";
import StatusNotifier from "./StatusNotifier";
import { getAvailableSheets, detectHeadersAndColumns, detectHeadersForSingleSheetRow, consolidateData } from "../excelLogic";
interface AppProps {
title: string;
}
const useStyles = makeStyles({
root: {
minHeight: "100vh",
display: "flex",
flexDirection: "column",
},
});
const App: React.FC<AppProps> = () => {
const styles = useStyles();
type WizardStep = "select_sheets" | "map_columns" | "done";
const [step, setStep] = useState<WizardStep>("select_sheets");
const [sheets, setSheets] = useState<SheetInfo[]>([]);
const [selectedSheetIds, setSelectedSheetIds] = useState<string[]>([]);
const [sheetMappings, setSheetMappings] = useState<SheetMappingStatus[]>([]);
const [status, setStatus] = useState<"idle" | "success" | "warning" | "error">("idle");
const [statusMessage, setStatusMessage] = useState("");
const [isConsolidating, setIsConsolidating] = useState(false);
useEffect(() => {
// Load internal sheets on mount
getAvailableSheets().then(setSheets).catch(err => {
setStatus("error");
setStatusMessage("Fehler beim Laden der Arbeitsblätter: " + String(err));
});
}, []);
const handleExternalSheetsLoaded = (newExternalSheets: SheetInfo[]) => {
setSheets(prev => [...prev, ...newExternalSheets]);
};
const handleNextToMapping = async () => {
const selectedSheets = sheets.filter(s => selectedSheetIds.includes(s.id));
if (selectedSheets.length === 0) return;
try {
const mappings = await detectHeadersAndColumns(selectedSheets);
setSheetMappings(mappings);
setStep("map_columns");
setStatus("idle");
setStatusMessage("");
} catch (err) {
setStatus("error");
setStatusMessage("Fehler beim Analysieren der Blätter: " + String(err));
}
};
const handleHeaderRowChange = async (sheetName: string, newRowIndex: number) => {
try {
// Wir müssen das richtige SheetInfo Objekt finden (für isExternal check)
const sheetInfo = sheets.find(s => s.name === sheetName);
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));
} catch (err) {
setStatus("error");
setStatusMessage("Fehler beim Neuladen der Zeile " + newRowIndex + " für Blatt " + sheetName);
}
};
const handleMappingChange = (sheetName: string, targetCol: string, sourceColIndex: number) => {
setSheetMappings(prev => prev.map(sheet => {
if (sheet.sheetName !== sheetName) return sheet;
const newMappings = sheet.mappings.map(m =>
m.targetColumn === targetCol ? { ...m, sourceColumnIndex: sourceColIndex } : m
);
return { ...sheet, mappings: newMappings };
}));
};
const handleConsolidate = async () => {
setIsConsolidating(true);
setStatus("idle");
try {
const rowsCount = await consolidateData(sheetMappings);
setStatus("success");
setStatusMessage(`Erfolgreich! Es wurden ${rowsCount} Zeilen aus ${sheetMappings.length} Blättern zusammengefasst.`);
setStep("done");
} catch (err: any) {
setStatus("error");
setStatusMessage(err.message || "Fehler bei der Konsolidierung: " + String(err));
} finally {
setIsConsolidating(false);
}
};
return (
<div className={styles.root}>
<StatusNotifier status={status} message={statusMessage} />
{step === "select_sheets" && (
<SheetSelector
sheets={sheets}
selectedSheetIds={selectedSheetIds}
onSelectionChange={setSelectedSheetIds}
onNext={handleNextToMapping}
onExternalSheetsLoaded={handleExternalSheetsLoaded}
/>
)}
{step === "map_columns" && (
<ColumnMapper
sheetMappings={sheetMappings}
onHeaderRowChange={handleHeaderRowChange}
onMappingChange={handleMappingChange}
onBack={() => setStep("select_sheets")}
onConsolidate={handleConsolidate}
isConsolidating={isConsolidating}
/>
)}
{step === "done" && (
<div style={{ padding: "10px", textAlign: "center", marginTop: "40px" }}>
<h2>Fertig!</h2>
<p>Die Daten wurden in die 'Gesamtliste' geschrieben.</p>
<button
style={{ padding: "8px 16px", marginTop: "10px", cursor: "pointer" }}
onClick={() => {
setStep("select_sheets");
setSelectedSheetIds([]);
setStatus("idle");
}}
>
Neuen Durchlauf starten
</button>
</div>
)}
</div>
);
};
export default App;

View File

@@ -0,0 +1,137 @@
import * as React from "react";
import {
Text,
Button,
SpinButton,
Dropdown,
Option,
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Field,
Label,
Spinner,
} from "@fluentui/react-components";
import { SheetMappingStatus, TARGET_COLUMNS } from "../models";
interface ColumnMapperProps {
sheetMappings: SheetMappingStatus[];
onHeaderRowChange: (sheetName: string, newRowIndex: number) => void;
onMappingChange: (sheetName: string, targetCol: string, sourceColIndex: number) => void;
onBack: () => void;
onConsolidate: () => void;
isConsolidating: boolean;
}
const ColumnMapper: React.FC<ColumnMapperProps> = ({
sheetMappings,
onHeaderRowChange,
onMappingChange,
onBack,
onConsolidate,
isConsolidating,
}) => {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "15px", padding: "10px" }}>
<Text size={400} weight="semibold">
2. Spalten-Mapping prüfen
</Text>
<Text size={300}>
Bitte überprüfe die gefundenen Kopfzeilen und passe fehlende Spalten manuell an.
</Text>
<Accordion multiple collapsible defaultOpenItems={sheetMappings.map((s) => s.sheetName)}>
{sheetMappings.map((sheet) => {
const missingCount = sheet.mappings.filter((m) => m.sourceColumnIndex === -1).length;
return (
<AccordionItem key={sheet.sheetName} value={sheet.sheetName}>
<AccordionHeader>
<div style={{ display: "flex", justifyContent: "space-between", width: "100%" }}>
<Text weight="semibold">{sheet.sheetName}</Text>
{missingCount > 0 ? (
<Text style={{ color: "red", paddingRight: "10px" }}>{missingCount} Lücken</Text>
) : (
<Text style={{ color: "green", paddingRight: "10px" }}>OK</Text>
)}
</div>
</AccordionHeader>
<AccordionPanel>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{missingCount === TARGET_COLUMNS.length && (
<div style={{ backgroundColor: "#fdf6f6", padding: "10px", borderRadius: "4px", border: "1px solid #f5b0b0" }}>
<Text style={{ color: "red", fontWeight: "semibold" }}>Achtung: Keine passenden Kopfzeilen gefunden.</Text><br />
<Text size={200}>Bitte weise die Spalten manuell zu. Wenn du dieses Blatt überspringen willst, lass einfach alles auf "Nicht gefunden".</Text>
</div>
)}
<Field
label="Kopfzeile (Index 0-basiert):"
orientation="horizontal"
>
<SpinButton
value={sheet.headerRowIndex}
min={0}
onChange={(_, data) => {
if (data.value !== undefined) {
onHeaderRowChange(sheet.sheetName, data.value);
}
}}
style={{ width: "80px" }}
/>
</Field>
{TARGET_COLUMNS.map((tc) => {
const colName = tc.id;
const mappedInfo = sheet.mappings.find((m) => m.targetColumn === colName);
const selectedVal = mappedInfo?.sourceColumnIndex !== -1 ? mappedInfo?.sourceColumnIndex.toString() : "-1";
return (
<Field key={colName} label={colName} orientation="horizontal" style={{ justifyContent: "space-between" }}>
<Dropdown
value={
selectedVal === "-1"
? "--- Nicht gefunden ---"
: sheet.availableColumns.find((c) => c.index.toString() === selectedVal)?.name || "Unbekannt"
}
selectedOptions={[selectedVal || "-1"]}
onOptionSelect={(_, data) => {
const newIndex = parseInt(data.optionValue || "-1", 10);
onMappingChange(sheet.sheetName, colName, newIndex);
}}
style={{ minWidth: "150px" }}
>
<Option value="-1" text="--- Nicht gefunden ---">--- Nicht gefunden ---</Option>
{sheet.availableColumns.map((availCol) => (
<Option key={availCol.index} value={availCol.index.toString()} text={`${availCol.name} (Spalte ${availCol.index + 1})`}>
{availCol.name} (Spalte {availCol.index + 1})
</Option>
))}
</Dropdown>
</Field>
);
})}
</div>
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: "20px" }}>
<Button onClick={onBack} disabled={isConsolidating}>Zurück</Button>
<Button
appearance="primary"
onClick={onConsolidate}
disabled={isConsolidating || sheetMappings.length === 0}
icon={isConsolidating ? <Spinner size="tiny" /> : undefined}
>
{isConsolidating ? "Konsolidierung läuft..." : "Konsolidieren"}
</Button>
</div>
</div>
);
};
export default ColumnMapper;

View File

@@ -0,0 +1,145 @@
import * as React from "react";
import { Checkbox, Text, Button, Divider } from "@fluentui/react-components";
import { SheetInfo } from "../models";
import * as XLSX from "xlsx";
interface SheetSelectorProps {
sheets: SheetInfo[];
selectedSheetIds: string[];
onSelectionChange: (selectedIds: string[]) => void;
onNext: () => void;
onExternalSheetsLoaded: (externalSheets: SheetInfo[]) => void;
}
const SheetSelector: React.FC<SheetSelectorProps> = ({
sheets,
selectedSheetIds,
onSelectionChange,
onNext,
onExternalSheetsLoaded,
}) => {
const handleCheckboxChange = (sheetId: string, checked: boolean) => {
if (checked) {
onSelectionChange([...selectedSheetIds, sheetId]);
} else {
onSelectionChange(selectedSheetIds.filter((id) => id !== sheetId));
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;
const newExternalSheets: SheetInfo[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const arrayBuffer = await file.arrayBuffer();
// Einlesen der Datei mit SheetJS
const workbook = XLSX.read(arrayBuffer, { type: "array" });
workbook.SheetNames.forEach((sheetName) => {
const worksheet = workbook.Sheets[sheetName];
// Lese Daten als 2D-Array (header: 1 bedeutet Array of Arrays)
// blankrows: false überspringt komplett leere Zeilen beim Einlesen nicht zwingend,
// aber sheet_to_json mit header:1 hält die Struktur relativ gut.
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" }) as any[][];
// Nur hinzufügen, wenn auch Daten drin sind
if (data.length > 0) {
const uniqueId = `ext_${file.name}_${sheetName}`;
newExternalSheets.push({
id: uniqueId,
name: sheetName,
isExternal: true,
fileName: file.name,
externalData: data
});
}
});
}
// Neue Blätter an die Hauptkomponente übergeben
if (newExternalSheets.length > 0) {
onExternalSheetsLoaded(newExternalSheets);
// Automatisch die neuen Blätter selektieren
onSelectionChange([...selectedSheetIds, ...newExternalSheets.map(s => s.id)]);
}
};
// Gruppiere Blätter für die Anzeige
const internalSheets = sheets.filter(s => !s.isExternal);
const externalSheets = sheets.filter(s => s.isExternal);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "10px", padding: "10px" }}>
<Text size={400} weight="semibold">
1. Quell-Blätter auswählen
</Text>
<Text size={300}>Bitte wähle alle Tabellenblätter aus, die konsolidiert werden sollen:</Text>
{/* Interne Blätter */}
<div style={{ marginTop: "10px" }}>
<Text weight="semibold" style={{ marginBottom: "5px", display: "block" }}>
Aus dieser Arbeitsmappe:
</Text>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
{internalSheets.length === 0 ? (
<Text italic>Keine internen Blätter gefunden.</Text>
) : (
internalSheets.map((s) => (
<Checkbox
key={s.id}
label={s.name}
checked={selectedSheetIds.includes(s.id)}
onChange={(_, data) => handleCheckboxChange(s.id, !!data.checked)}
/>
))
)}
</div>
</div>
<Divider style={{ margin: "10px 0" }} />
{/* Externe Blätter */}
<div>
<Text weight="semibold" style={{ marginBottom: "5px", display: "block" }}>
Aus anderen Dateien hinzufügen:
</Text>
<input
type="file"
accept=".xlsx, .xlsm, .xls, .csv"
multiple
onChange={handleFileUpload}
style={{ marginBottom: "10px", display: "block" }}
/>
{externalSheets.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "5px", marginTop: "10px" }}>
{externalSheets.map((s) => (
<Checkbox
key={s.id}
label={`${s.fileName} - ${s.name}`}
checked={selectedSheetIds.includes(s.id)}
onChange={(_, data) => handleCheckboxChange(s.id, !!data.checked)}
/>
))}
</div>
)}
</div>
<div style={{ marginTop: "20px" }}>
<Button
appearance="primary"
disabled={selectedSheetIds.length === 0}
onClick={onNext}
>
Weiter zum Mapping
</Button>
</div>
</div>
);
};
export default SheetSelector;

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import { MessageBar, MessageBarBody, MessageBarTitle } from "@fluentui/react-components";
interface StatusNotifierProps {
status: "idle" | "success" | "warning" | "error";
title?: string;
message?: string;
}
const StatusNotifier: React.FC<StatusNotifierProps> = ({ status, title, message }) => {
if (status === "idle" || !message) {
return null;
}
return (
<div style={{ marginTop: "10px", padding: "0 10px" }}>
<MessageBar intent={status}>
<MessageBarBody>
{title && <MessageBarTitle>{title}</MessageBarTitle>}
{message}
</MessageBarBody>
</MessageBar>
</div>
);
};
export default StatusNotifier;