feat: enhance CSV import functionality, integrate react-query for data fetching, and refactor admin components
This commit is contained in:
@@ -10,6 +10,8 @@ const Admin: React.FC = () => {
|
||||
() => Cookies.get("token") ?? null
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderAdmin
|
||||
|
@@ -4,15 +4,18 @@ type CircleUploadProps = {
|
||||
onFiles?: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
setFiles: (files: File[]) => void;
|
||||
files?: File[];
|
||||
};
|
||||
|
||||
const CircleUpload: React.FC<CircleUploadProps> = ({
|
||||
onFiles,
|
||||
accept = "",
|
||||
multiple = true,
|
||||
setFiles,
|
||||
files = [],
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const processFiles = useCallback(
|
||||
|
@@ -23,6 +23,7 @@ const HeaderAdmin: React.FC<HeaderAdminProps> = ({
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 md:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Ticket className="h-8 w-8 text-black" strokeWidth={2.6} />
|
||||
|
||||
<h1 className="text-2xl font-black tracking-tight text-neutral-900 md:text-3xl">
|
||||
Admin Panel
|
||||
</h1>
|
||||
|
@@ -1,12 +1,15 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import CircleUpload from "./CircleUpload";
|
||||
import { CircleX } from "lucide-react";
|
||||
import { postCSV } from "../utils/fileHandler";
|
||||
|
||||
type ImportGUIProps = {
|
||||
onClose: () => void;
|
||||
setFiles: (files: File[]) => void;
|
||||
files?: File[];
|
||||
};
|
||||
|
||||
const ImportGUI: React.FC<ImportGUIProps> = ({ onClose }) => {
|
||||
const ImportGUI: React.FC<ImportGUIProps> = ({ onClose, setFiles, files }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||
{/* Backdrop */}
|
||||
@@ -50,7 +53,7 @@ const ImportGUI: React.FC<ImportGUIProps> = ({ onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<CircleUpload accept=".csv" />
|
||||
<CircleUpload accept=".csv" setFiles={setFiles} files={files} />
|
||||
</div>
|
||||
|
||||
{/* Placeholder for optional file summary / mapping UI */}
|
||||
@@ -70,7 +73,12 @@ const ImportGUI: React.FC<ImportGUIProps> = ({ onClose }) => {
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white shadow transition hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled
|
||||
disabled={!files || files.length === 0}
|
||||
onClick={() => {
|
||||
if (files && files.length) {
|
||||
postCSV(files[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Importieren
|
||||
</button>
|
||||
|
@@ -3,8 +3,13 @@ import { Sheet, WholeWord } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ImportGUI from "./ImportGUI";
|
||||
|
||||
type SubHeaderAdminProps = {
|
||||
setFiles: (files: File[]) => void;
|
||||
files?: File[];
|
||||
};
|
||||
|
||||
// Sub navigation bar for admin views: provides import + clear selection actions
|
||||
const SubHeaderAdmin: React.FC = () => {
|
||||
const SubHeaderAdmin: React.FC<SubHeaderAdminProps> = ({ setFiles, files }) => {
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -48,7 +53,13 @@ const SubHeaderAdmin: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{showImport && <ImportGUI onClose={() => setShowImport(false)} />}
|
||||
{showImport && (
|
||||
<ImportGUI
|
||||
onClose={() => setShowImport(false)}
|
||||
setFiles={setFiles}
|
||||
files={files}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTableData, readCachedTableData } from "../utils/userHandler";
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import SubHeaderAdmin from "./SubHeaderAdmin";
|
||||
@@ -15,9 +16,9 @@ interface DataPackage {
|
||||
}
|
||||
|
||||
const Table: React.FC = () => {
|
||||
const [rows, setRows] = useState<DataPackage[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
// Hilfsfunktion zum Einlesen & Normalisieren der LocalStorage-Daten
|
||||
const loadFromCache = () => {
|
||||
@@ -27,32 +28,35 @@ const Table: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
// Server könnte entweder ein Objekt oder ein Array liefern
|
||||
const normalized: DataPackage[] = Array.isArray(cached)
|
||||
? cached
|
||||
: [cached];
|
||||
const normalized: DataPackage[] = Array.isArray(cached) ? cached : [cached];
|
||||
setRows(normalized);
|
||||
};
|
||||
|
||||
const token = Cookies.get("token") || "";
|
||||
|
||||
const tableQuery = useQuery({
|
||||
queryKey: ["table-data", token],
|
||||
enabled: !!token,
|
||||
queryFn: async () => {
|
||||
const data = await getTableData(token);
|
||||
if (data === null) throw new Error("Fehler beim Laden der Daten.");
|
||||
return data as unknown as DataPackage[] | DataPackage; // server may send single object
|
||||
},
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
// Sync normalized cached data into local state whenever query succeeds or cache changes
|
||||
useEffect(() => {
|
||||
// Initial lokale Daten laden (falls schon vorhanden)
|
||||
loadFromCache();
|
||||
}, [tableQuery.data]);
|
||||
|
||||
// Frische Daten vom Backend holen
|
||||
const token = Cookies.get("token") || "";
|
||||
if (!token) return; // Kein Token => nur Cache anzeigen
|
||||
|
||||
setLoading(true);
|
||||
getTableData(token)
|
||||
.then((data) => {
|
||||
if (data === null) {
|
||||
setError("Fehler beim Laden der Daten.");
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
loadFromCache();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (tableQuery.isError) {
|
||||
setError((tableQuery.error as Error).message);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
}, [tableQuery.isError, tableQuery.error]);
|
||||
|
||||
// Reagieren auf LocalStorage-Änderungen (z.B. in anderen Tabs)
|
||||
useEffect(() => {
|
||||
@@ -70,15 +74,22 @@ const Table: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubHeaderAdmin />
|
||||
<SubHeaderAdmin setFiles={setFiles} files={files} />
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
{loading && (
|
||||
{(tableQuery.isLoading || tableQuery.isFetching) && (
|
||||
<span className="text-xs text-blue-600 animate-pulse">
|
||||
Laden...
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tableQuery.refetch()}
|
||||
className="text-xs rounded border px-2 py-1 text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg shadow ring-1 ring-black/5">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
@@ -133,7 +144,7 @@ const Table: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{rows.length === 0 && !loading && (
|
||||
{rows.length === 0 && !tableQuery.isLoading && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
|
@@ -1,10 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
11
frontend/src/queryClient.ts
Normal file
11
frontend/src/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Central QueryClient instance so utilities (e.g. file upload) can invalidate queries.
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
@@ -1,3 +1,6 @@
|
||||
import { myToast } from "./toastify";
|
||||
import { queryClient } from "../queryClient";
|
||||
|
||||
export function downloadHandler(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -9,3 +12,55 @@ export function downloadHandler(filename: string, content: string) {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function postCSV(file: File): Promise<boolean> {
|
||||
// Reads a CSV file client-side and returns a Promise<string[]> where each
|
||||
// entry is one non-empty trimmed line from the file. Uses the lightweight
|
||||
// split-lines library to normalize different newline styles.
|
||||
const readerLines = new Promise<string[]>((resolve, reject) => {
|
||||
if (!file) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () =>
|
||||
reject(reader.error ?? new Error("Failed to read file"));
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// Lazy import to avoid bundling if unused elsewhere
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import("split-lines")
|
||||
.then(({ default: splitLines }) => {
|
||||
const text = String(reader.result ?? "");
|
||||
// split-lines preserves empty lines if keepNewlines = false; we filter afterwards
|
||||
const lines = splitLines(text, { preserveNewlines: false })
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
resolve(lines);
|
||||
})
|
||||
.catch((err) => reject(err));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file, "utf-8");
|
||||
});
|
||||
|
||||
const res = await fetch("http://localhost:8002/create-entry", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
losnummer: await readerLines,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
myToast("CSV Datei erfolgreich importiert!", "success");
|
||||
queryClient.invalidateQueries({ queryKey: ["table-data"] });
|
||||
return true;
|
||||
}
|
||||
myToast("Fehler beim Importieren der CSV Datei.", "error");
|
||||
return false;
|
||||
}
|
||||
|
Reference in New Issue
Block a user