Initial commit
This commit is contained in:
526
src/components/PatList/PatList.jsx
Normal file
526
src/components/PatList/PatList.jsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Trash2,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
|
||||
const CALENDAR_DAY_LABELS = ['M', 'D', 'M', 'D', 'F', 'S', 'S'];
|
||||
|
||||
const toIsoDate = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const parseIsoDate = (value) => {
|
||||
if (!value || !value.includes('-')) return null;
|
||||
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
const getCalendarDays = (monthDate) => {
|
||||
const startOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
|
||||
const offset = (startOfMonth.getDay() + 6) % 7;
|
||||
const calendarStart = new Date(startOfMonth.getFullYear(), startOfMonth.getMonth(), 1 - offset);
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const day = new Date(calendarStart.getFullYear(), calendarStart.getMonth(), calendarStart.getDate() + index);
|
||||
|
||||
return {
|
||||
key: toIsoDate(day),
|
||||
date: day,
|
||||
isoValue: toIsoDate(day),
|
||||
isCurrentMonth: day.getMonth() === monthDate.getMonth()
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const PatList = ({
|
||||
patTypes,
|
||||
assessments,
|
||||
overview,
|
||||
assessmentShareTokens = {},
|
||||
onCreate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
getPatTypeColor,
|
||||
getAchievement
|
||||
}) => {
|
||||
const datePickerRef = useRef(null);
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||
const [activeDatePicker, setActiveDatePicker] = useState(null);
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'datum', direction: 'desc' });
|
||||
const [filters, setFilters] = useState({ name: '', datum: '' });
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => {
|
||||
const today = new Date();
|
||||
return new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
});
|
||||
const patTypeOptions = Object.keys(patTypes || {});
|
||||
const openAssessments = assessments.filter((assessment) => !assessment.isFinalized);
|
||||
const finalizedAssessments = assessments.filter((assessment) => assessment.isFinalized);
|
||||
|
||||
const getResultValue = (assessment) =>
|
||||
assessment.exercises.reduce((sum, ex) => sum + (ex.points || 0), 0);
|
||||
|
||||
const normalize = (value) => String(value || '').toLowerCase().trim();
|
||||
const formatDateForFilter = (value) => {
|
||||
if (!value || !value.includes('-')) return '';
|
||||
const [year, month, day] = value.split('-');
|
||||
return `${day}.${month}.${year}`;
|
||||
};
|
||||
const formatDateForRow = (value) => {
|
||||
if (!value || !value.includes('-')) return value || '';
|
||||
const [year, month, day] = value.split('-');
|
||||
return `${year}.${month}.${day}`;
|
||||
};
|
||||
const monthLabel = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(visibleMonth),
|
||||
[visibleMonth]
|
||||
);
|
||||
const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeDatePicker) return undefined;
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
if (datePickerRef.current && !datePickerRef.current.contains(event.target)) {
|
||||
setActiveDatePicker(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setActiveDatePicker(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [activeDatePicker]);
|
||||
|
||||
const matchesFilters = (assessment) => {
|
||||
const matchesName = !filters.name || normalize(assessment.name).includes(normalize(filters.name));
|
||||
const matchesDate = !filters.datum || normalize(assessment.datum).includes(normalize(filters.datum));
|
||||
|
||||
return matchesName && matchesDate;
|
||||
};
|
||||
|
||||
const getSortValue = (assessment, key) => {
|
||||
if (key === 'name') return (assessment.name || '').toLowerCase();
|
||||
if (key === 'datum') return assessment.datum || '';
|
||||
if (key === 'result') return getResultValue(assessment);
|
||||
return '';
|
||||
};
|
||||
|
||||
const sortAssessments = (entries) =>
|
||||
[...entries].sort((left, right) => {
|
||||
const leftValue = getSortValue(left, sortConfig.key);
|
||||
const rightValue = getSortValue(right, sortConfig.key);
|
||||
|
||||
if (leftValue < rightValue) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (leftValue > rightValue) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sortedOpenAssessments = useMemo(
|
||||
() => sortAssessments(openAssessments.filter(matchesFilters)),
|
||||
[openAssessments, sortConfig, filters]
|
||||
);
|
||||
const sortedFinalizedAssessments = useMemo(
|
||||
() => sortAssessments(finalizedAssessments.filter(matchesFilters)),
|
||||
[finalizedAssessments, sortConfig, filters]
|
||||
);
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSortConfig((current) =>
|
||||
current.key === key
|
||||
? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { key, direction: key === 'datum' ? 'desc' : 'asc' }
|
||||
);
|
||||
};
|
||||
|
||||
const renderSortIcon = (key) => {
|
||||
if (sortConfig.key !== key) {
|
||||
return <ArrowUpDown className="w-4 h-4 text-gray-400 dark:text-gray-500" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return sortConfig.direction === 'asc' ? (
|
||||
<ChevronUp className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" aria-hidden="true" />
|
||||
);
|
||||
};
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const syncVisibleMonth = (value) => {
|
||||
const parsedDate = parseIsoDate(value);
|
||||
const baseDate = parsedDate || new Date();
|
||||
setVisibleMonth(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
|
||||
};
|
||||
|
||||
const toggleDatePicker = (tableKey) => {
|
||||
setActiveDatePicker((current) => {
|
||||
if (current !== tableKey) {
|
||||
syncVisibleMonth(filters.datum);
|
||||
return tableKey;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const selectDate = (value) => {
|
||||
handleFilterChange('datum', value);
|
||||
syncVisibleMonth(value);
|
||||
setActiveDatePicker(null);
|
||||
};
|
||||
|
||||
const clearDate = () => {
|
||||
handleFilterChange('datum', '');
|
||||
syncVisibleMonth('');
|
||||
setActiveDatePicker(null);
|
||||
};
|
||||
|
||||
const selectToday = () => {
|
||||
selectDate(toIsoDate(new Date()));
|
||||
};
|
||||
|
||||
const renderAssessmentTable = (tableKey, title, entries, totalEntries, emptyMessage) => {
|
||||
const isDatePickerOpen = activeDatePicker === tableKey;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/70">
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-gray-100">{title}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Anzahl: {entries.length}
|
||||
{entries.length !== totalEntries ? ` von ${totalEntries}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalEntries === 0 ? (
|
||||
<div className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800 text-left">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('name')}
|
||||
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||
>
|
||||
Name
|
||||
{renderSortIcon('name')}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.name}
|
||||
onChange={(event) => handleFilterChange('name', event.target.value)}
|
||||
aria-label="Nach Name filtern"
|
||||
className="w-40 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-sm font-normal text-gray-700 dark:text-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('datum')}
|
||||
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||
>
|
||||
Datum
|
||||
{renderSortIcon('datum')}
|
||||
</button>
|
||||
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDatePicker(tableKey)}
|
||||
aria-label="Nach Datum filtern"
|
||||
aria-expanded={isDatePickerOpen}
|
||||
className="flex w-36 items-center justify-between rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-950 px-3 py-2 text-left text-sm font-normal text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<span>{formatDateForFilter(filters.datum) || 'tt.mm.jjjj'}</span>
|
||||
<Calendar className="h-4 w-4 text-gray-500 dark:text-gray-400" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{isDatePickerOpen && (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-xl border border-gray-200 bg-white p-3 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setVisibleMonth(
|
||||
(current) => new Date(current.getFullYear(), current.getMonth() - 1, 1)
|
||||
)
|
||||
}
|
||||
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label="Vorheriger Monat"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold capitalize text-gray-800 dark:text-gray-100">
|
||||
{monthLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setVisibleMonth(
|
||||
(current) => new Date(current.getFullYear(), current.getMonth() + 1, 1)
|
||||
)
|
||||
}
|
||||
className="rounded-lg p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||
aria-label="Nächster Monat"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{CALENDAR_DAY_LABELS.map((label, index) => (
|
||||
<span key={`${label}-${index}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-7 gap-1">
|
||||
{calendarDays.map((day) => {
|
||||
const isSelected = day.isoValue === filters.datum;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.key}
|
||||
type="button"
|
||||
onClick={() => selectDate(day.isoValue)}
|
||||
className={`flex h-10 items-center justify-center rounded-lg text-sm transition ${
|
||||
isSelected
|
||||
? 'bg-indigo-600 text-white'
|
||||
: day.isCurrentMonth
|
||||
? 'text-gray-800 hover:bg-gray-100 dark:text-gray-100 dark:hover:bg-gray-800'
|
||||
: 'text-gray-400 hover:bg-gray-100 dark:text-gray-600 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={selectToday}
|
||||
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearDate}
|
||||
className="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort('result')}
|
||||
className="inline-flex items-center gap-2 hover:text-gray-900 dark:hover:text-white transition"
|
||||
>
|
||||
Ergebnis
|
||||
{renderSortIcon('result')}
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-200 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 ? (
|
||||
<tr className="border-t border-gray-200 dark:border-gray-800">
|
||||
<td colSpan="4" className="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : entries.map((assessment) => {
|
||||
const shareToken = assessmentShareTokens[assessment.id];
|
||||
const hasShare = Boolean(shareToken);
|
||||
const totalPoints = getResultValue(assessment);
|
||||
const achievement = getAchievement(assessment.patType, totalPoints);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={assessment.id}
|
||||
className="border-t border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{assessment.name}</span>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold ${getPatTypeColor(
|
||||
assessment.patType
|
||||
)}`}
|
||||
>
|
||||
{assessment.patType}
|
||||
</span>
|
||||
{hasShare && (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200">
|
||||
Freigabe aktiv
|
||||
</span>
|
||||
)}
|
||||
{assessment.isFinalized && (
|
||||
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200">
|
||||
Final abgeschlossen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" aria-hidden="true" />
|
||||
{formatDateForRow(assessment.datum)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl font-bold text-green-600 dark:text-green-300">
|
||||
{totalPoints.toFixed(0)}
|
||||
</span>
|
||||
<span className={`inline-flex px-2 py-1 rounded text-xs font-semibold ${achievement.color} ${achievement.text}`}>
|
||||
{achievement.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(assessment)}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700 transition text-sm"
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(assessment.id);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 p-2"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-blue-100 dark:from-gray-950 dark:to-gray-900 p-6 text-gray-900 dark:text-gray-100">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 mb-6 border border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">PAT Test Manager</h1>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCreateMenu((current) => !current)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neue Bewertung
|
||||
</button>
|
||||
|
||||
{showCreateMenu && (
|
||||
<div className="absolute right-0 top-full mt-2 w-48 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg z-10 overflow-hidden">
|
||||
{patTypeOptions.map((patType) => (
|
||||
<button
|
||||
key={patType}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false);
|
||||
onCreate(patType);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
>
|
||||
{patType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{overview && <div className="mt-6">{overview}</div>}
|
||||
|
||||
{assessments.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{renderAssessmentTable(
|
||||
'open',
|
||||
'Noch offene Bewertungen',
|
||||
sortedOpenAssessments,
|
||||
openAssessments.length,
|
||||
'Keine offenen Bewertungen mit den aktuellen Suchfiltern gefunden'
|
||||
)}
|
||||
{renderAssessmentTable(
|
||||
'finalized',
|
||||
'Abgeschlossene Bewertungen',
|
||||
sortedFinalizedAssessments,
|
||||
finalizedAssessments.length,
|
||||
'Keine abgeschlossenen Bewertungen mit den aktuellen Suchfiltern gefunden'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessments.length === 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-12 text-center border border-gray-100 dark:border-gray-800">
|
||||
<User className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-700" aria-hidden="true" />
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">Noch keine Bewertungen vorhanden</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Lege oben eine neue Bewertung an und wähle dabei PAT Start, PAT 1, PAT 2 oder PAT 3</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatList;
|
||||
Reference in New Issue
Block a user