Initial Project
This commit is contained in:
19
src/commands/commands.html
Normal file
19
src/commands/commands.html
Normal file
@@ -0,0 +1,19 @@
|
||||
@@ -1,18 +0,0 @@
|
||||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
|
||||
<!-- Office JavaScript API -->
|
||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
21
src/commands/commands.ts
Normal file
21
src/commands/commands.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
|
||||
* See LICENSE in the project root for license information.
|
||||
*/
|
||||
|
||||
/* global Office */
|
||||
|
||||
Office.onReady(() => {
|
||||
// If needed, Office.js is ready to be called.
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows a notification when the add-in command is executed.
|
||||
* @param event
|
||||
*/
|
||||
function action(event: Office.AddinCommands.Event) {
|
||||
// Your code here
|
||||
event.completed();
|
||||
}
|
||||
|
||||
Office.actions.associate("action", action);
|
||||
151
src/taskpane/components/App.tsx
Normal file
151
src/taskpane/components/App.tsx
Normal 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;
|
||||
137
src/taskpane/components/ColumnMapper.tsx
Normal file
137
src/taskpane/components/ColumnMapper.tsx
Normal 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;
|
||||
145
src/taskpane/components/SheetSelector.tsx
Normal file
145
src/taskpane/components/SheetSelector.tsx
Normal 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;
|
||||
27
src/taskpane/components/StatusNotifier.tsx
Normal file
27
src/taskpane/components/StatusNotifier.tsx
Normal 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;
|
||||
255
src/taskpane/excelLogic.ts
Normal file
255
src/taskpane/excelLogic.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/* global Excel, console */
|
||||
import { SheetInfo, SheetMappingStatus, TARGET_COLUMNS } from "./models";
|
||||
|
||||
/**
|
||||
* Holt alle sichtbaren Arbeitsblätter des aktuellen Workbooks, außer "Gesamtliste".
|
||||
*/
|
||||
export async function getAvailableSheets(): Promise<SheetInfo[]> {
|
||||
return Excel.run(async (context) => {
|
||||
const sheets = context.workbook.worksheets;
|
||||
sheets.load("items/name, items/visibility");
|
||||
await context.sync();
|
||||
|
||||
return sheets.items
|
||||
.filter((sheet) => sheet.name !== "Gesamtliste" && sheet.visibility === Excel.SheetVisibility.visible)
|
||||
.map((sheet) => ({
|
||||
id: sheet.name,
|
||||
name: sheet.name,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert für ausgewählte Blätter die Kopfzeile und das Mapping-Ergebnis.
|
||||
* Die Funktion durchsucht die ersten 50 Zeilen (inkl. leerer Zeilen),
|
||||
* um die eigentlichen Spaltennamen zu finden.
|
||||
*/
|
||||
export async function detectHeadersAndColumns(sheets: SheetInfo[]): Promise<SheetMappingStatus[]> {
|
||||
return Excel.run(async (context) => {
|
||||
const results: SheetMappingStatus[] = [];
|
||||
|
||||
for (const sheetInfo of sheets) {
|
||||
let values: any[][] = [];
|
||||
|
||||
if (sheetInfo.isExternal && sheetInfo.externalData) {
|
||||
// Bei externen Dateien nehmen wir einfach die ersten 50 Zeilen aus den geparsten Daten
|
||||
values = sheetInfo.externalData.slice(0, 50);
|
||||
} else {
|
||||
// Bei internen Dateien laden wir die Daten über die Excel API
|
||||
const sheet = context.workbook.worksheets.getItem(sheetInfo.name);
|
||||
const range = sheet.getRange("A1:AX50");
|
||||
range.load("values");
|
||||
await context.sync();
|
||||
values = range.values;
|
||||
}
|
||||
|
||||
let bestRowIndex = 0;
|
||||
let maxMatches = -1;
|
||||
|
||||
// Finde die Zeile, die die meisten Übereinstimmungen mit unseren Zielspalten hat
|
||||
for (let r = 0; r < values.length; r++) {
|
||||
const rowData = values[r];
|
||||
if (!rowData) continue; // Safety check in case external sheet has sparse arrays
|
||||
|
||||
let matches = 0;
|
||||
for (let c = 0; c < rowData.length; c++) {
|
||||
const cellStr = String(rowData[c]).trim().toLowerCase();
|
||||
if (TARGET_COLUMNS.some((tc) => tc.aliases.includes(cellStr))) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
if (matches > maxMatches) {
|
||||
maxMatches = matches;
|
||||
bestRowIndex = r;
|
||||
}
|
||||
}
|
||||
|
||||
// Sicherstellen, dass values[bestRowIndex] existiert (falls Blatt komplett leer ist)
|
||||
// Wenn maxMatches immer noch -1 ist (oder 0), dann wurde absolut nichts gefunden
|
||||
const headerRow = (maxMatches > 0 && values[bestRowIndex]) ? values[bestRowIndex] : [];
|
||||
const status = buildSheetMappingStatus(sheetInfo, headerRow, maxMatches > 0 ? bestRowIndex : 0);
|
||||
results.push(status);
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erlaubt das Neuladen für eine spezifische (vom Nutzer geänderte) Kopfzeile.
|
||||
*/
|
||||
export async function detectHeadersForSingleSheetRow(sheetInfo: SheetInfo, rowIndex: number): Promise<SheetMappingStatus> {
|
||||
if (sheetInfo.isExternal && sheetInfo.externalData) {
|
||||
// Externe Datei
|
||||
const headerRow = sheetInfo.externalData[rowIndex] || [];
|
||||
return buildSheetMappingStatus(sheetInfo, headerRow, rowIndex);
|
||||
} else {
|
||||
// Interne Datei
|
||||
return Excel.run(async (context) => {
|
||||
const sheet = context.workbook.worksheets.getItem(sheetInfo.name);
|
||||
// rowIndex ist 0-basiert, Range ist 1-basiert
|
||||
const range = sheet.getRangeByIndexes(rowIndex, 0, 1, 50); // Lese Spalten 0-49 in der gewünschten Zeile
|
||||
range.load("values");
|
||||
await context.sync();
|
||||
|
||||
return buildSheetMappingStatus(sheetInfo, range.values[0], rowIndex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion zum Erstellen des State-Objekts für das Mapping.
|
||||
*/
|
||||
function buildSheetMappingStatus(sheetInfo: SheetInfo, headerRow: any[], rowIndex: number): SheetMappingStatus {
|
||||
const availableColumns = headerRow.map((val, idx) => ({
|
||||
name: String(val).trim() || `(Leere Spalte ${idx + 1})`,
|
||||
index: idx,
|
||||
})).filter(col => col.name !== `(Leere Spalte ${col.index + 1})`);
|
||||
|
||||
const mappings = TARGET_COLUMNS.map((tc) => {
|
||||
const foundIdx = headerRow.findIndex((cell) => tc.aliases.includes(String(cell).trim().toLowerCase()));
|
||||
return {
|
||||
targetColumn: tc.id,
|
||||
sourceColumnIndex: foundIdx,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sheetName: sheetInfo.name, // Für Anzeige
|
||||
headerRowIndex: rowIndex,
|
||||
availableColumns,
|
||||
mappings,
|
||||
isExternal: sheetInfo.isExternal,
|
||||
externalData: sheetInfo.externalData
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt die eigentliche Konsolidierung aus allen Arbeitsblättern durch und schreibt
|
||||
* das Ergebnis in das Blatt "Gesamtliste".
|
||||
*/
|
||||
export async function consolidateData(mappings: SheetMappingStatus[]): Promise<number> {
|
||||
return Excel.run(async (context) => {
|
||||
let rowsConsolidated = 0;
|
||||
const finalData: any[][] = [];
|
||||
|
||||
// Daten auslesen und mergen
|
||||
for (const mapping of mappings) {
|
||||
if (mapping.mappings.length === 0) continue;
|
||||
|
||||
let textValues: any[][] = [];
|
||||
let dataStartRowOffset = 0;
|
||||
const targetIndicesMap = mapping.mappings.map(m => m.sourceColumnIndex);
|
||||
|
||||
if (mapping.isExternal && mapping.externalData) {
|
||||
// Bei externen Dateien
|
||||
textValues = mapping.externalData;
|
||||
dataStartRowOffset = mapping.headerRowIndex + 1;
|
||||
} else {
|
||||
// Bei internen Dateien
|
||||
const sheet = context.workbook.worksheets.getItem(mapping.sheetName);
|
||||
const usedRange = sheet.getUsedRange();
|
||||
usedRange.load(["rowIndex", "rowCount", "columnCount", "text"]);
|
||||
await context.sync();
|
||||
|
||||
const startRowIdx = usedRange.rowIndex;
|
||||
// Offset berechnen: Wie viele Zeilen nach dem Start des UsedRange liegt die erste Datenzeile?
|
||||
dataStartRowOffset = (mapping.headerRowIndex + 1) - startRowIdx;
|
||||
|
||||
// Wenn die Daten unterhalb des UsedRange beginnen oder das Blatt leer ist
|
||||
if (dataStartRowOffset >= usedRange.rowCount || usedRange.rowCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textValues = usedRange.text; // Text returns the exact formatted strings
|
||||
}
|
||||
|
||||
// Schleife ab Zeile nach Header bis Ende
|
||||
for (let r = Math.max(0, dataStartRowOffset); r < textValues.length; r++) {
|
||||
const textRow = textValues[r] || [];
|
||||
|
||||
// Prüfen ob Row komplett leer ist (oder zumindest in den Mapped Columns leer)
|
||||
const isRowEmpty = targetIndicesMap.every(srcColIdx => {
|
||||
if (srcColIdx === -1) return true;
|
||||
const val = textRow[srcColIdx];
|
||||
return val === null || val === undefined || String(val).trim() === "";
|
||||
});
|
||||
|
||||
if (isRowEmpty) continue;
|
||||
|
||||
const consolidatedRow: any[] = [];
|
||||
// Die 6 Standard Target Columns
|
||||
for (const srcColIdx of targetIndicesMap) {
|
||||
if (srcColIdx !== -1 && srcColIdx < textRow.length) {
|
||||
consolidatedRow.push(String(textRow[srcColIdx]));
|
||||
} else {
|
||||
consolidatedRow.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// Zusatzfelder für die Kabelliste: Länge, gezogen am, von (Monteur)
|
||||
consolidatedRow.push("");
|
||||
consolidatedRow.push("");
|
||||
consolidatedRow.push("");
|
||||
|
||||
finalData.push(consolidatedRow);
|
||||
rowsConsolidated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Kabelliste erstellen oder überschreiben
|
||||
let targetSheet: Excel.Worksheet;
|
||||
try {
|
||||
targetSheet = context.workbook.worksheets.getItem("Kabelliste");
|
||||
// Falls sie existiert, zuerst löschen, um sie neu zu erstellen
|
||||
targetSheet.delete();
|
||||
await context.sync();
|
||||
} catch (e) {
|
||||
// Ignorieren (Blatt existiert noch nicht)
|
||||
}
|
||||
|
||||
try {
|
||||
targetSheet = context.workbook.worksheets.add("Kabelliste");
|
||||
|
||||
// Header Zeile schreiben
|
||||
const fullHeaders = [...TARGET_COLUMNS.map(tc => tc.id), "Länge", "gezogen am", "von (Monteur)"];
|
||||
|
||||
// finalData hat fullHeaders.length Spalten
|
||||
const totalRowsCount = finalData.length + 1; // +1 für Header
|
||||
const totalColsCount = fullHeaders.length;
|
||||
|
||||
const targetRange = targetSheet.getRangeByIndexes(0, 0, totalRowsCount, totalColsCount);
|
||||
|
||||
const allValues = [fullHeaders, ...finalData];
|
||||
|
||||
// Formatiere die Zielzellen als Text ("@"), BEVOR die Werte reingeschrieben werden,
|
||||
// damit Excel nicht versucht, Datumsstrings als numerische Datumsformate umzuwandeln.
|
||||
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();
|
||||
|
||||
// Als Tabelle formatieren
|
||||
const table = targetSheet.tables.add(targetRange, true /* hasHeaders */);
|
||||
table.name = "KonsolidierteKabel";
|
||||
table.style = "TableStyleLight9";
|
||||
table.showFilterButton = true;
|
||||
|
||||
// Spaltenbreite anpassen (AutoFit)
|
||||
targetRange.format.autofitColumns();
|
||||
await context.sync();
|
||||
|
||||
targetSheet.activate();
|
||||
|
||||
return rowsConsolidated;
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Erstellen der Kabelliste:", error);
|
||||
throw new Error("Fehler beim Erstellen der 'Kabelliste'. Möglicherweise ist die Arbeitsmappe schreibgeschützt.");
|
||||
}
|
||||
});
|
||||
}
|
||||
27
src/taskpane/index.tsx
Normal file
27
src/taskpane/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./components/App";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
/* global document, Office, module, require, HTMLElement */
|
||||
|
||||
const title = "Contoso Task Pane Add-in";
|
||||
|
||||
const rootElement: HTMLElement | null = document.getElementById("container");
|
||||
const root = rootElement ? createRoot(rootElement) : undefined;
|
||||
|
||||
/* Render application after Office initializes */
|
||||
Office.onReady(() => {
|
||||
root?.render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<App title={title} />
|
||||
</FluentProvider>
|
||||
);
|
||||
});
|
||||
|
||||
if ((module as any).hot) {
|
||||
(module as any).hot.accept("./components/App", () => {
|
||||
const NextApp = require("./components/App").default;
|
||||
root?.render(NextApp);
|
||||
});
|
||||
}
|
||||
36
src/taskpane/models.ts
Normal file
36
src/taskpane/models.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface SheetInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isExternal?: boolean; // True if loaded from an external file
|
||||
fileName?: string; // The name of the external file
|
||||
externalData?: any[][]; // The raw parsed data from SheetJS
|
||||
}
|
||||
|
||||
export interface ColumnMappingInfo {
|
||||
targetColumn: string;
|
||||
sourceColumnIndex: number; // -1 if not found
|
||||
}
|
||||
|
||||
export interface SheetMappingStatus {
|
||||
sheetName: string;
|
||||
headerRowIndex: number; // 0-indexed
|
||||
mappings: ColumnMappingInfo[];
|
||||
availableColumns: { name: string; index: number }[];
|
||||
isExternal?: boolean;
|
||||
externalData?: any[][];
|
||||
}
|
||||
|
||||
export interface TargetColumnDef {
|
||||
id: string; // e.g. "K-Nr."
|
||||
aliases: string[]; // e.g. ["k-nr.", "kabelnummer", "nr."]
|
||||
}
|
||||
|
||||
export const TARGET_COLUMNS: TargetColumnDef[] = [
|
||||
{ id: "K-Nr.", aliases: ["k-nr.", "k-nr", "kabelnummer", "kabel", "nummer", "nr.", "nr"] },
|
||||
{ id: "Bezeichnung", aliases: ["bezeichnung", "name", "titel", "beschreibung"] },
|
||||
{ id: "von", aliases: ["von", "start", "quelle", "ursprung"] },
|
||||
{ id: "von Raum", aliases: ["von raum", "raum von", "raum (von)", "startraum"] },
|
||||
{ id: "nach", aliases: ["nach", "ziel", "ende", "destination"] },
|
||||
{ id: "nach Raum", aliases: ["nach raum", "raum nach", "raum (nach)", "zielraum"] },
|
||||
{ id: "Kabeltyp", aliases: ["kabeltyp", "typ", "kabelart", "art", "querschnitt"] }
|
||||
];
|
||||
41
src/taskpane/taskpane.html
Normal file
41
src/taskpane/taskpane.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -->
|
||||
<!-- See LICENSE in the project root for license information -->
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-framework="typescript">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Contoso Task Pane Add-in</title>
|
||||
|
||||
<!-- Office JavaScript API -->
|
||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
|
||||
<body style="width: 100%; height: 100%; margin: 0; padding: 0;">
|
||||
<div id="container"></div>
|
||||
|
||||
<!--
|
||||
Fluent UI React v. 9 uses modern JavaScript syntax that is not supported in
|
||||
Trident (Internet Explorer) or EdgeHTML (Edge Legacy), so this add-in won't
|
||||
work in Office versions that use these webviews. The script below makes the
|
||||
following div display when an unsupported webview is in use, and hides the
|
||||
React container div.
|
||||
-->
|
||||
<div id="tridentmessage" style="display: none; padding: 10;">
|
||||
This add-in will not run in your version of Office. Please upgrade either to perpetual Office 2021 (or later)
|
||||
or to a Microsoft 365 account.
|
||||
</div>
|
||||
<script>
|
||||
if ((navigator.userAgent.indexOf("Trident") !== -1) || (navigator.userAgent.indexOf("Edge") !== -1)) {
|
||||
var tridentMessage = document.getElementById("tridentmessage");
|
||||
var normalContainer = document.getElementById("container");
|
||||
tridentMessage.style.display = "block";
|
||||
normalContainer.style.display = "none";
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
16
src/taskpane/taskpane.ts
Normal file
16
src/taskpane/taskpane.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/* global Excel console */
|
||||
|
||||
export async function insertText(text: string) {
|
||||
// Write text to the top left cell.
|
||||
try {
|
||||
await Excel.run(async (context) => {
|
||||
const sheet = context.workbook.worksheets.getActiveWorksheet();
|
||||
const range = sheet.getRange("A1");
|
||||
range.values = [[text]];
|
||||
range.format.autofitColumns();
|
||||
await context.sync();
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error: " + error);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user