282 lines
15 KiB
React
282 lines
15 KiB
React
/* Hallmark · pre-emit critique: P5 H5 E5 S5 R5 V5 */
|
|
/* Hallmark · component: shift-matrix-table · genre: editorial · theme: Atelier (Dark & Light) · studied-DNA: https://inihaus.de */
|
|
import React from 'react';
|
|
import { Clock, UserCheck, Shield, Printer, Download, Award, CheckCircle2, UserX, UserPlus, MapPin, Edit3, Trash2 } from 'lucide-react';
|
|
|
|
export default function ShiftMatrixTable({
|
|
event,
|
|
user,
|
|
onSignupClick,
|
|
onCancelClick,
|
|
onRemoveUserFromShift,
|
|
onGenerateClaimLink,
|
|
onExportPdf,
|
|
onPrintView,
|
|
onEditEvent,
|
|
onDeleteEvent
|
|
}) {
|
|
const [guestDisplayName, setGuestDisplayName] = React.useState(() => localStorage.getItem('guest_display_name') || '');
|
|
|
|
React.useEffect(() => {
|
|
const handleUpdate = () => setGuestDisplayName(localStorage.getItem('guest_display_name') || '');
|
|
window.addEventListener('guest_name_updated', handleUpdate);
|
|
window.addEventListener('storage', handleUpdate);
|
|
return () => {
|
|
window.removeEventListener('guest_name_updated', handleUpdate);
|
|
window.removeEventListener('storage', handleUpdate);
|
|
};
|
|
}, []);
|
|
if (!event || !event.task_areas || event.task_areas.length === 0) {
|
|
return (
|
|
<div className="hallmark-panel rounded-sm p-12 text-center border border-grid font-mono">
|
|
<Clock className="w-8 h-8 text-muted mx-auto mb-3" />
|
|
<h3 className="font-serif text-xl font-bold text-main uppercase">Keine Aufgabenfelder vorhanden</h3>
|
|
<p className="text-xs text-muted mt-1 mb-4">[ Event hat noch keine definierten Aufgabenfelder oder Schichten ]</p>
|
|
{user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || (event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id)))) && onEditEvent && (
|
|
<button
|
|
onClick={() => onEditEvent(event)}
|
|
className="px-4 py-2 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 font-bold text-xs inline-flex items-center gap-2"
|
|
>
|
|
<Edit3 className="w-4 h-4" /> SCHICHTEN & BEREICHE ANLEGEN
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isGuest = !user;
|
|
const isCoManager = user && event?.co_managers && event.co_managers.some(cm => (typeof cm === 'object' ? cm.id === user.id : cm === user.id));
|
|
const isManager = user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id || isCoManager);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Event Header Panel */}
|
|
<div className="hallmark-panel rounded-sm p-6 sm:p-8 border border-grid flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div className="space-y-1 font-mono">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted mb-1">
|
|
<span className="bg-subtle px-2.5 py-0.5 rounded-sm border border-grid font-bold text-main">
|
|
{new Date(event.start_date).toLocaleDateString('de-DE')} — {new Date(event.end_date).toLocaleDateString('de-DE')}
|
|
</span>
|
|
{event.location && (
|
|
<span className="flex items-center gap-1 text-muted">
|
|
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
|
{event.location}
|
|
</span>
|
|
)}
|
|
{event.is_active === false && (
|
|
<span className="px-2 py-0.5 rounded-sm bg-amber-500/10 text-amber-500 border border-amber-500/20 font-bold">
|
|
[ DEAKTIVIERT ]
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h2 className="font-serif text-3xl sm:text-4xl font-bold uppercase tracking-tight text-main">{event.title}</h2>
|
|
{event.description && <p className="text-xs font-sans text-muted max-w-2xl mt-1">{event.description}</p>}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 no-print font-mono text-xs shrink-0">
|
|
{isManager && onEditEvent && (
|
|
<button
|
|
onClick={() => onEditEvent(event)}
|
|
className="px-3.5 py-1.5 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 transition flex items-center gap-1.5 font-bold"
|
|
title="Schichten und Aufgabenbereiche für diese Veranstaltung bearbeiten"
|
|
>
|
|
<Edit3 className="w-3.5 h-3.5" /> SCHICHTEN BEARBEITEN
|
|
</button>
|
|
)}
|
|
{user && (user.is_admin_user || user.is_staff || user.is_superuser || event?.created_by === user.id || event?.created_by?.id === user.id) && onDeleteEvent && (
|
|
<button
|
|
onClick={() => onDeleteEvent(event)}
|
|
className="px-3.5 py-1.5 rounded-sm bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/30 transition flex items-center gap-1.5 font-bold"
|
|
title="Diese Veranstaltung unwiderruflich löschen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" /> LÖSCHEN
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onPrintView}
|
|
className="px-3.5 py-1.5 rounded-sm bg-surface hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5 font-bold"
|
|
>
|
|
<Printer className="w-3.5 h-3.5" /> DRUCKEN
|
|
</button>
|
|
<button
|
|
onClick={onExportPdf}
|
|
className="px-3.5 py-1.5 rounded-sm bg-surface hover:bg-surface-hover text-main border border-grid transition flex items-center gap-1.5 font-bold"
|
|
>
|
|
<Download className="w-3.5 h-3.5" /> PDF
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Guest Privacy Banner */}
|
|
{isGuest && (
|
|
<div className="p-3.5 rounded-sm bg-amber-500/10 border border-amber-500/20 text-amber-500 text-xs font-mono flex items-center gap-2">
|
|
<Shield className="w-4 h-4 shrink-0 text-amber-500" />
|
|
<span>
|
|
[ GAST-DATENSCHUTZ ]: Belegungszahlen sichtbar, Namensanzeige aus Datenschutzgründen <strong>anonymisiert</strong>.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hallmark Matrix Schedule Table */}
|
|
<div className="hallmark-panel rounded-sm border border-grid overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="bg-subtle border-b border-grid text-[11px] font-mono text-muted uppercase tracking-wider">
|
|
<th className="py-3.5 px-4 w-1/4">Aufgabenfeld</th>
|
|
<th className="py-3.5 px-4 w-1/4">Schicht & Zeitraum</th>
|
|
<th className="py-3.5 px-4 w-1/6">Qualifikation</th>
|
|
<th className="py-3.5 px-4 w-1/4">Belegung & Personen</th>
|
|
<th className="py-3.5 px-4 w-1/6 text-right">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-grid text-xs font-sans">
|
|
{event.task_areas.map((area) => {
|
|
if (!area.shifts || area.shifts.length === 0) {
|
|
return (
|
|
<tr key={`area-${area.id}`}>
|
|
<td className="py-3.5 px-4 font-serif text-lg font-bold text-main uppercase">{area.name}</td>
|
|
<td colSpan={4} className="py-3.5 px-4 text-muted font-mono text-[11px]">
|
|
[ Keine Schichten angelegt ]
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
return area.shifts.map((shift, idx) => {
|
|
const isFirst = idx === 0;
|
|
const isFull = shift.is_full;
|
|
|
|
const userSignup = user ? shift.signups.find(s => !s.is_guest && s.display_name === user.display_name) : null;
|
|
const isUserSignedUp = Boolean(userSignup);
|
|
|
|
const hasSkillReqs = shift.required_skills && shift.required_skills.length > 0;
|
|
|
|
return (
|
|
<tr key={`shift-${shift.id}`} className="hover:bg-surface-hover/60 transition">
|
|
{isFirst ? (
|
|
<td
|
|
rowSpan={area.shifts.length}
|
|
className="py-3.5 px-4 font-serif text-lg font-bold text-main uppercase bg-subtle/80 align-top border-r border-grid"
|
|
>
|
|
<div>{area.name}</div>
|
|
{area.description && <div className="text-[11px] font-sans font-normal text-muted mt-0.5">{area.description}</div>}
|
|
</td>
|
|
) : null}
|
|
|
|
<td className="py-3.5 px-4">
|
|
<div className="font-semibold text-main flex items-center gap-2 flex-wrap">
|
|
<span>{shift.title}</span>
|
|
{shift.date && (
|
|
<span className="text-[10px] font-mono font-bold text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded-sm border border-indigo-500/20">
|
|
📅 {new Date(shift.date + 'T00:00:00').toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] font-mono text-muted flex items-center gap-1 mt-0.5">
|
|
<Clock className="w-3 h-3 text-muted shrink-0" />
|
|
<span>{shift.start_time} — {shift.end_time} Uhr</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="py-3.5 px-4 font-mono">
|
|
{hasSkillReqs ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{shift.required_skills.map((s) => (
|
|
<span
|
|
key={s.id}
|
|
style={{ backgroundColor: `${s.color}15`, borderColor: `${s.color}35`, color: s.color }}
|
|
className="px-2 py-0.5 rounded-sm text-[10px] font-semibold border flex items-center gap-1"
|
|
>
|
|
<Award className="w-3 h-3 shrink-0" /> {s.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-[11px] text-muted">—</span>
|
|
)}
|
|
</td>
|
|
|
|
<td className="py-3.5 px-4 font-mono">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-2 py-0.5 rounded-sm text-[11px] font-bold border ${
|
|
isFull
|
|
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
|
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
|
}`}>
|
|
[ {shift.signups_count} / {shift.max_participants} BELEGT ]
|
|
</span>
|
|
</div>
|
|
|
|
{/* Attendees List */}
|
|
<div className="mt-2 space-y-1 font-sans">
|
|
{shift.signups.map((su) => {
|
|
const isOwnGuest = isGuest && su.is_guest && guestDisplayName && su.display_name?.trim().toLowerCase() === guestDisplayName.trim().toLowerCase();
|
|
return (
|
|
<div key={su.id} className={`text-[11px] text-muted flex items-center justify-between gap-2 p-1.5 rounded-sm border ${isOwnGuest ? 'bg-amber-500/10 border-amber-500/30' : 'bg-subtle border-grid'}`}>
|
|
<div className="flex items-center gap-1.5 truncate">
|
|
<UserCheck className={`w-3 h-3 shrink-0 ${isOwnGuest ? 'text-amber-400' : 'text-muted'}`} />
|
|
<span className={`truncate ${isOwnGuest ? 'font-bold text-amber-300' : ''}`}>{su.display_name}</span>
|
|
{su.is_guest && <span className="text-[9px] font-mono text-amber-500 px-1 rounded-sm bg-amber-500/10 border border-amber-500/20 font-bold">GAST</span>}
|
|
{isOwnGuest && <span className="text-[9px] font-mono text-emerald-400 px-1 rounded-sm bg-emerald-500/10 border border-emerald-500/20 font-bold">DU</span>}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{user && su.is_guest && onGenerateClaimLink && (
|
|
<button
|
|
onClick={() => onGenerateClaimLink(su)}
|
|
className="text-indigo-400 hover:text-indigo-300 transition p-1 rounded-sm bg-indigo-500/10 hover:bg-indigo-500/20 border border-indigo-500/20 flex items-center gap-1 text-[10px] font-mono font-bold"
|
|
title="Konto-Einladungslink für diese Gast-Eintragung kopieren"
|
|
>
|
|
<UserPlus className="w-3 h-3" /> LINK
|
|
</button>
|
|
)}
|
|
|
|
{isManager && onRemoveUserFromShift && (
|
|
<button
|
|
onClick={() => onRemoveUserFromShift(shift, su.id)}
|
|
className="text-muted hover:text-red-500 transition p-1 rounded-sm hover:bg-red-500/10"
|
|
title="Person aus dieser Schicht entfernen"
|
|
>
|
|
<UserX className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</td>
|
|
|
|
<td className="py-3.5 px-4 text-right font-mono">
|
|
{isUserSignedUp ? (
|
|
<button
|
|
onClick={() => onCancelClick(shift)}
|
|
className="px-3 py-1.5 rounded-sm text-xs font-semibold bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 transition flex items-center gap-1 ml-auto"
|
|
>
|
|
<UserX className="w-3.5 h-3.5" /> AUSTRAGEN
|
|
</button>
|
|
) : isFull ? (
|
|
<span className="text-xs text-muted font-bold">[ VOLL ]</span>
|
|
) : (
|
|
<button
|
|
onClick={() => onSignupClick(shift)}
|
|
style={{ backgroundColor: 'var(--brand-primary)' }}
|
|
className="px-3.5 py-1.5 rounded-sm text-xs font-bold text-white transition hover:brightness-110 flex items-center gap-1 ml-auto shadow-sm"
|
|
>
|
|
<CheckCircle2 className="w-3.5 h-3.5" /> EINTRAGEN
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
});
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|