532 lines
23 KiB
JavaScript
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;
|