feat(academic-periods): period selector, active period

API, holiday indicators; UI polish; bump version

Dashboard:

Add Syncfusion academic period dropdown next to group selector
Navigate scheduler to today's month/day within selected period year on change
Show adjacent holiday plan badge; keep "holidays in view" counter on the right
Compact dropdown widths for a tighter toolbar
Default blocking of scheduling on holidays; block entries styled like all-day; black text styling
API:

Add academic periods routes: list, get active, set active (POST), for_date
Register blueprint in wsgi
Holidays:

Support TXT/CSV upload; headerless TXT uses columns 2-4; region remains null
Docs:

Update shared Copilot instructions with academic periods endpoints and dashboard integration details
This commit is contained in:
2025-09-21 14:35:38 +00:00
parent 41194000a4
commit eaf6e32446
9 changed files with 640 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.6",
"version": "2025.1.0-alpha.7",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,18 @@
"commitId": "8d1df7199cb7"
},
"changelog": [
{
"version": "2025.1.0-alpha.7",
"date": "2025-09-21",
"changes": [
"🧭 UI: Periode-Auswahl (Syncfusion) neben Gruppenauswahl; kompaktes Layout",
"✅ Anzeige: Abzeichen für vorhandenen Ferienplan + Zähler Ferien im Blick",
"🛠️ API: Endpunkte für akademische Perioden (list, active GET/POST, for_date)",
"📅 Scheduler: Standardmäßig keine Terminierung in Ferien; Block-Darstellung wie Ganztagesereignis; schwarze Textfarbe",
"📤 Ferien: Upload von TXT/CSV (headless TXT nutzt Spalten 24)",
"🔧 UX: Schalter in einer Reihe; Dropdown-Breiten optimiert"
]
},
{
"version": "2025.1.0-alpha.6",
"date": "2025-09-20",

View File

@@ -0,0 +1,42 @@
export type AcademicPeriod = {
id: number;
name: string;
display_name?: string | null;
start_date: string; // YYYY-MM-DD
end_date: string; // YYYY-MM-DD
period_type: 'schuljahr' | 'semester' | 'trimester';
is_active: boolean;
};
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: AcademicPeriod | null }>(
`/api/academic_periods/for_date?date=${iso}`
);
return period ?? null;
}
export async function listAcademicPeriods(): Promise<AcademicPeriod[]> {
const { periods } = await api<{ periods: AcademicPeriod[] }>(`/api/academic_periods`);
return Array.isArray(periods) ? periods : [];
}
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`);
return period ?? null;
}
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
return period;
}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
ScheduleComponent,
Day,
@@ -23,7 +23,21 @@ import { getGroupColor } from './groupColors';
import { deleteEvent } from './apiEvents';
import CustomEventModal from './components/CustomEventModal';
import { fetchMediaById } from './apiClients';
import { Presentation, Globe, Video, MessageSquare, School } from 'lucide-react';
import { listHolidays, type Holiday } from './apiHolidays';
import {
getAcademicPeriodForDate,
listAcademicPeriods,
setActiveAcademicPeriod,
} from './apiAcademicPeriods';
import {
Presentation,
Globe,
Video,
MessageSquare,
School,
CheckCircle,
AlertCircle,
} from 'lucide-react';
import { renderToStaticMarkup } from 'react-dom/server';
import caGregorian from './cldr/ca-gregorian.json';
import numbers from './cldr/numbers.json';
@@ -43,6 +57,8 @@ type Event = {
StartTime: Date;
EndTime: Date;
IsAllDay: boolean;
IsBlock?: boolean; // Syncfusion block appointment
isHoliday?: boolean; // marker for styling/logic
MediaId?: string | number;
SlideshowInterval?: number;
WebsiteUrl?: string;
@@ -123,15 +139,15 @@ const eventTemplate = (event: Event) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#fff', marginBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#000', marginBottom: 2 }}>
{IconComponent && (
<span style={{ verticalAlign: 'middle', display: 'inline-block', marginRight: 6 }}>
<IconComponent size={18} color="#fff" />
<IconComponent size={18} color="#000" />
</span>
)}
<span style={{ marginTop: 3 }}>{event.Subject}</span>
</div>
<div style={{ fontSize: '0.95em', color: '#fff', marginTop: -2 }}>{timeString}</div>
<div style={{ fontSize: '0.95em', color: '#000', marginTop: -2 }}>{timeString}</div>
</div>
);
};
@@ -140,11 +156,20 @@ const Appointments: React.FC = () => {
const [groups, setGroups] = useState<Group[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0);
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
const [showInactive, setShowInactive] = React.useState(true);
const [allowScheduleOnHolidays, setAllowScheduleOnHolidays] = React.useState(false);
const [showHolidayList, setShowHolidayList] = React.useState(true);
const scheduleRef = React.useRef<ScheduleComponent | null>(null);
const [holidaysInView, setHolidaysInView] = React.useState<number>(0);
const [schoolYearLabel, setSchoolYearLabel] = React.useState<string>('');
const [hasSchoolYearPlan, setHasSchoolYearPlan] = React.useState<boolean>(false);
const [periods, setPeriods] = React.useState<{ id: number; label: string }[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
// Gruppen laden
useEffect(() => {
@@ -158,6 +183,24 @@ const Appointments: React.FC = () => {
.catch(console.error);
}, []);
// Holidays laden
useEffect(() => {
listHolidays()
.then(res => setHolidays(res.holidays || []))
.catch(err => console.error('Ferien laden fehlgeschlagen:', err));
}, []);
// Perioden laden (Dropdown)
useEffect(() => {
listAcademicPeriods()
.then(all => {
setPeriods(all.map(p => ({ id: p.id, label: p.display_name || p.name })));
const active = all.find(p => p.is_active);
setActivePeriodId(active ? active.id : null);
})
.catch(err => console.error('Akademische Perioden laden fehlgeschlagen:', err));
}, []);
// fetchAndSetEvents als useCallback definieren, damit die Dependency korrekt ist:
const fetchAndSetEvents = React.useCallback(async () => {
if (!selectedGroupId) {
@@ -178,7 +221,7 @@ const Appointments: React.FC = () => {
}));
setEvents(mapped);
} catch (err) {
console.error(err);
console.error('Fehler beim Laden der Termine:', err);
}
}, [selectedGroupId, showInactive]);
@@ -191,11 +234,168 @@ const Appointments: React.FC = () => {
}
}, [selectedGroupId, showInactive, fetchAndSetEvents]);
// Helper: prüfe, ob Zeitraum einen Feiertag/Ferienbereich schneidet
const isWithinHolidayRange = React.useCallback(
(start: Date, end: Date) => {
// normalisiere Endzeit minimal (Syncfusion nutzt exklusive Enden in einigen Fällen)
const adjEnd = new Date(end);
// keine Änderung nötig unsere eigenen Events sind präzise
for (const h of holidays) {
// Holiday dates are strings YYYY-MM-DD (local date)
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (
(start >= hs && start <= he) ||
(adjEnd >= hs && adjEnd <= he) ||
(start <= hs && adjEnd >= he)
) {
return true;
}
}
return false;
},
[holidays]
);
// Baue Holiday-Anzeige-Events und Block-Events
const holidayDisplayEvents: Event[] = useMemo(() => {
if (!showHolidayList) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-display`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
isHoliday: true,
});
}
return out;
}, [holidays, showHolidayList]);
const holidayBlockEvents: Event[] = useMemo(() => {
if (allowScheduleOnHolidays) return [];
const out: Event[] = [];
for (const h of holidays) {
const start = new Date(h.start_date + 'T00:00:00');
const end = new Date(h.end_date + 'T23:59:59');
out.push({
Id: `holiday-${h.id}-block`,
Subject: h.name,
StartTime: start,
EndTime: end,
IsAllDay: true,
IsBlock: true,
isHoliday: true,
});
}
return out;
}, [holidays, allowScheduleOnHolidays]);
const dataSource = useMemo(() => {
return [...events, ...holidayDisplayEvents, ...holidayBlockEvents];
}, [events, holidayDisplayEvents, holidayBlockEvents]);
// Aktive akademische Periode für Datum aus dem Backend ermitteln
const refreshAcademicPeriodFor = React.useCallback(
async (baseDate: Date) => {
try {
const p = await getAcademicPeriodForDate(baseDate);
if (!p) {
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
return;
}
// Anzeige: bevorzugt display_name, sonst name
const label = p.display_name ? p.display_name : p.name;
setSchoolYearLabel(label);
// Existiert ein Ferienplan innerhalb der Periode?
const start = new Date(p.start_date + 'T00:00:00');
const end = new Date(p.end_date + 'T23:59:59');
let exists = false;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
if (hs <= end && he >= start) {
exists = true;
break;
}
}
setHasSchoolYearPlan(exists);
} catch (e) {
console.error('Akademische Periode laden fehlgeschlagen:', e);
setSchoolYearLabel('');
setHasSchoolYearPlan(false);
}
},
[holidays]
);
// Anzahl an Ferienzeiträumen in aktueller Ansicht ermitteln + Perioden-Indikator setzen
const updateHolidaysInView = React.useCallback(() => {
const inst = scheduleRef.current;
if (!inst) {
setHolidaysInView(0);
return;
}
const view = inst.currentView as 'Day' | 'Week' | 'WorkWeek' | 'Month' | 'Agenda';
const baseDate = inst.selectedDate as Date;
if (!baseDate) {
setHolidaysInView(0);
return;
}
let rangeStart = new Date(baseDate);
let rangeEnd = new Date(baseDate);
if (view === 'Day' || view === 'Agenda') {
rangeStart.setHours(0, 0, 0, 0);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Week' || view === 'WorkWeek') {
const day = baseDate.getDay();
const diffToMonday = (day + 6) % 7; // Monday=0
rangeStart = new Date(baseDate);
rangeStart.setDate(baseDate.getDate() - diffToMonday);
rangeStart.setHours(0, 0, 0, 0);
rangeEnd = new Date(rangeStart);
rangeEnd.setDate(rangeStart.getDate() + 6);
rangeEnd.setHours(23, 59, 59, 999);
} else if (view === 'Month') {
rangeStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1, 0, 0, 0, 0);
rangeEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0, 23, 59, 59, 999);
}
let count = 0;
for (const h of holidays) {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
const overlaps =
(hs >= rangeStart && hs <= rangeEnd) ||
(he >= rangeStart && he <= rangeEnd) ||
(hs <= rangeStart && he >= rangeEnd);
if (overlaps) count += 1;
}
setHolidaysInView(count);
// Perioden-Indikator über Backend prüfen
refreshAcademicPeriodFor(baseDate);
}, [holidays, refreshAcademicPeriodFor]);
// Aktualisiere Indikator wenn Ferien oder Ansicht (Key) wechseln
React.useEffect(() => {
updateHolidaysInView();
}, [holidays, updateHolidaysInView, schedulerKey]);
return (
<div>
<h1 className="text-2xl font-bold mb-4">Terminmanagement</h1>
<div
style={{ marginBottom: 16, maxWidth: 500, display: 'flex', alignItems: 'center', gap: 12 }}
style={{
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap',
}}
>
<label htmlFor="groupDropdown" className="mb-0 mr-2" style={{ whiteSpace: 'nowrap' }}>
Raumgruppe auswählen:
@@ -206,13 +406,67 @@ const Appointments: React.FC = () => {
fields={{ text: 'name', value: 'id' }}
placeholder="Gruppe auswählen"
value={selectedGroupId}
width="240px"
change={(e: { value: string }) => {
// <--- Typ für e ergänzt
setEvents([]); // Events sofort leeren
setSelectedGroupId(e.value);
}}
style={{ flex: 1 }}
style={{}}
/>
{/* Akademische Periode Selector + Plan-Badge */}
<span style={{ marginLeft: 8, whiteSpace: 'nowrap' }}>Periode:</span>
<DropDownListComponent
id="periodDropdown"
dataSource={periods}
fields={{ text: 'label', value: 'id' }}
placeholder="Periode wählen"
value={activePeriodId ?? undefined}
width="260px"
change={async (e: { value: number }) => {
const id = Number(e.value);
if (!id) return;
try {
const updated = await setActiveAcademicPeriod(id);
setActivePeriodId(updated.id);
// Zum gleichen Tag/Monat (heute) innerhalb der gewählten Periode springen
const today = new Date();
const targetYear = new Date(updated.start_date).getFullYear();
const target = new Date(targetYear, today.getMonth(), today.getDate(), 12, 0, 0);
if (scheduleRef.current) {
scheduleRef.current.selectedDate = target;
scheduleRef.current.dataBind?.();
}
updateHolidaysInView();
} catch (err) {
console.error('Aktive Periode setzen fehlgeschlagen:', err);
}
}}
style={{}}
/>
{/* School-year/period plan badge (adjacent) */}
<span
title={hasSchoolYearPlan ? 'Ferienplan ist hinterlegt' : 'Kein Ferienplan hinterlegt'}
style={{
background: hasSchoolYearPlan ? '#dcfce7' : '#f3f4f6',
border: hasSchoolYearPlan ? '1px solid #86efac' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{hasSchoolYearPlan ? (
<CheckCircle size={14} color="#166534" />
) : (
<AlertCircle size={14} color="#6b7280" />
)}
{schoolYearLabel || 'Periode'}
</span>
</div>
<button
className="e-btn e-success mb-4"
@@ -239,7 +493,15 @@ const Appointments: React.FC = () => {
>
Neuen Termin anlegen
</button>
<div style={{ marginBottom: 16 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
marginBottom: 16,
flexWrap: 'wrap',
}}
>
<label>
<input
type="checkbox"
@@ -249,6 +511,41 @@ const Appointments: React.FC = () => {
/>
Vergangene Termine anzeigen
</label>
<label>
<input
type="checkbox"
checked={allowScheduleOnHolidays}
onChange={e => setAllowScheduleOnHolidays(e.target.checked)}
style={{ marginRight: 8 }}
/>
Termine an Ferientagen erlauben
</label>
<label>
<input
type="checkbox"
checked={showHolidayList}
onChange={e => setShowHolidayList(e.target.checked)}
style={{ marginRight: 8 }}
/>
Ferien im Kalender anzeigen
</label>
{/* Right-aligned indicators */}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}>
{/* Holidays-in-view badge */}
<span
title="Anzahl der Ferientage/-zeiträume in der aktuellen Ansicht"
style={{
background: holidaysInView > 0 ? '#ffe8cc' : '#f3f4f6',
border: holidaysInView > 0 ? '1px solid #ffcf99' : '1px solid #e5e7eb',
color: '#000',
padding: '4px 10px',
borderRadius: 16,
fontSize: 12,
}}
>
{holidaysInView > 0 ? `Ferien im Blick: ${holidaysInView}` : 'Keine Ferien in Ansicht'}
</span>
</div>
</div>
<CustomEventModal
open={modalOpen}
@@ -277,17 +574,26 @@ const Appointments: React.FC = () => {
groupName={groups.find(g => g.id === selectedGroupId) ?? { id: selectedGroupId, name: '' }}
groupColor={selectedGroupId ? getGroupColor(selectedGroupId, groups) : undefined}
editMode={editMode} // NEU: Prop für Editiermodus
blockHolidays={!allowScheduleOnHolidays}
isHolidayRange={(s, e) => isWithinHolidayRange(s, e)}
/>
<ScheduleComponent
ref={scheduleRef}
key={schedulerKey} // <-- dynamischer Key
height="750px"
locale="de"
currentView="Week"
eventSettings={{
dataSource: events,
dataSource: dataSource,
fields: { isBlock: 'IsBlock' },
template: eventTemplate, // <--- Hier das Template setzen!
}}
actionComplete={() => updateHolidaysInView()}
cellClick={args => {
if (!allowScheduleOnHolidays && isWithinHolidayRange(args.startTime, args.endTime)) {
args.cancel = true;
return; // block creation on holidays
}
// args.startTime und args.endTime sind Date-Objekte
args.cancel = true; // Verhindert die Standardaktion
setModalInitialData({
@@ -353,6 +659,20 @@ const Appointments: React.FC = () => {
}
}}
eventRendered={(args: EventRenderedArgs) => {
// Blende Nicht-Ferien-Events aus, falls sie in Ferien fallen und Terminieren nicht erlaubt ist
if (!allowScheduleOnHolidays && args.data && !args.data.isHoliday) {
const s =
args.data.StartTime instanceof Date
? args.data.StartTime
: new Date(args.data.StartTime);
const e =
args.data.EndTime instanceof Date ? args.data.EndTime : new Date(args.data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
if (selectedGroupId && args.data && args.data.Id) {
const groupColor = getGroupColor(selectedGroupId, groups);
const now = new Date();
@@ -389,13 +709,26 @@ const Appointments: React.FC = () => {
subjectText;
}
// Vergangene Termine: Raumgruppenfarbe mit Transparenz
// Vergangene Termine: Raumgruppenfarbe
if (args.data.EndTime && args.data.EndTime < now) {
args.element.style.backgroundColor = groupColor ? `${groupColor}` : '#f3f3f3';
args.element.style.color = '';
args.element.style.color = '#000';
} else if (groupColor) {
args.element.style.backgroundColor = groupColor;
args.element.style.color = '';
args.element.style.color = '#000';
}
// Spezielle Darstellung für Ferienanzeige-Events
if (args.data.isHoliday && !args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc'; // sanftes Orange
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
// Gleiche Darstellung für Ferien-Block-Events
if (args.data.isHoliday && args.data.IsBlock) {
args.element.style.backgroundColor = '#ffe8cc';
args.element.style.border = '1px solid #ffcf99';
args.element.style.color = '#000';
}
}
}}
@@ -429,6 +762,25 @@ const Appointments: React.FC = () => {
}
// Syncfusion soll das Event nicht selbst löschen
args.cancel = true;
} else if (
(args.requestType === 'eventCreate' || args.requestType === 'eventChange') &&
!allowScheduleOnHolidays
) {
// Verhindere Erstellen/Ändern in Ferienbereichen (Failsafe, falls eingebauter Editor genutzt wird)
type PartialEventLike = { StartTime?: Date | string; EndTime?: Date | string };
const raw = (args as ActionEventArgs).data as
| PartialEventLike
| PartialEventLike[]
| undefined;
const data = Array.isArray(raw) ? raw[0] : raw;
if (data && data.StartTime && data.EndTime) {
const s = data.StartTime instanceof Date ? data.StartTime : new Date(data.StartTime);
const e = data.EndTime instanceof Date ? data.EndTime : new Date(data.EndTime);
if (isWithinHolidayRange(s, e)) {
args.cancel = true;
return;
}
}
}
}}
firstDayOfWeek={1}

View File

@@ -32,6 +32,8 @@ type CustomEventModalProps = {
groupName: string | { id: string | null; name: string };
groupColor?: string;
editMode?: boolean;
blockHolidays?: boolean;
isHolidayRange?: (start: Date, end: Date) => boolean;
};
const weekdayOptions = [
@@ -60,6 +62,8 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
groupName,
groupColor,
editMode,
blockHolidays,
isHolidayRange,
}) => {
const [title, setTitle] = React.useState(initialData.title || '');
const [startDate, setStartDate] = React.useState(initialData.startDate || null);
@@ -149,6 +153,34 @@ const CustomEventModal: React.FC<CustomEventModalProps> = ({
if (!websiteUrl.trim()) newErrors.websiteUrl = 'Webseiten-URL ist erforderlich';
}
// Holiday blocking: prevent creating when range overlaps
if (
!editMode &&
blockHolidays &&
startDate &&
startTime &&
endTime &&
typeof isHolidayRange === 'function'
) {
const s = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startTime.getHours(),
startTime.getMinutes()
);
const e = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
endTime.getHours(),
endTime.getMinutes()
);
if (isHolidayRange(s, e)) {
newErrors.startDate = 'Dieser Zeitraum liegt in den Ferien und ist gesperrt.';
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;

View File

@@ -44,17 +44,21 @@ const Einstellungen: React.FC = () => {
<section className="p-4 border rounded-md">
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
<p className="text-sm text-gray-600 mb-2">
Laden Sie eine CSV-Datei mit den Spalten: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>.
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '}
<code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '}
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV importieren'}
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}