Files
Pat-Manager/src/components/PatList/PatList.jsx
2026-03-23 23:58:21 +01:00

532 lines
23 KiB
JavaScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowUpDown,
Calendar,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Plus,
Trash2,
User
} from 'lucide-react';
import { useTranslation } from '../../i18n/LanguageContext';
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 = {},
testsVisible = true,
onCreate,
onEdit,
onDelete,
getPatTypeColor,
getAchievement
}) => {
const t = useTranslation();
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">
{t('patlist.count', { count: entries.length })}
{entries.length !== totalEntries ? ` ${t('patlist.of')} ${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"
>
{t('patlist.col_name')}
{renderSortIcon('name')}
</button>
<input
type="text"
value={filters.name}
onChange={(event) => handleFilterChange('name', event.target.value)}
aria-label={t('patlist.filter_name')}
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"
>
{t('patlist.col_date')}
{renderSortIcon('datum')}
</button>
<div ref={isDatePickerOpen ? datePickerRef : null} className="relative">
<button
type="button"
onClick={() => toggleDatePicker(tableKey)}
aria-label={t('patlist.filter_date')}
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) || t('patlist.date_placeholder')}</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={t('patlist.prev_month')}
>
<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={t('patlist.next_month')}
>
<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">
{t('patlist.days').split(' ').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"
>
{t('patlist.today')}
</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"
>
{t('patlist.delete')}
</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"
>
{t('patlist.col_result')}
{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 ${
testsVisible
? 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
}`}>
{testsVisible ? t('patlist.share_active') : t('patlist.share_paused')}
</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">
{t('patlist.finalized')}
</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"
>
{t('patlist.open')}
</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={t('patlist.delete')}
>
<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">
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">{t('patlist.title')}</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" />
{t('patlist.new_assessment')}
</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',
t('patlist.open_assessments'),
sortedOpenAssessments,
openAssessments.length,
t('patlist.no_open')
)}
{renderAssessmentTable(
'finalized',
t('patlist.closed_assessments'),
sortedFinalizedAssessments,
finalizedAssessments.length,
t('patlist.no_closed')
)}
</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">{t('patlist.no_assessments')}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('patlist.no_assessments_hint')}</p>
</div>
)}
</div>
</div>
);
};
export default PatList;