Rollen Chunk 6: Team-Tab mit Matrix-Kalender und Genehmigungen
This commit is contained in:
1 parent
49f8805c5d
commit
54970b97e8
1 file changed
+352
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
decideVacation,
|
||||
getTeam,
|
||||
type TeamMember,
|
||||
} from "@/lib/api-client";
|
||||
import { weekdayShort, shortGermanDate } from "@/lib/format";
|
||||
|
||||
function pluralDays(units: number): string {
|
||||
const days = units / 2;
|
||||
return days === 1 ? "1 Tag" : `${days.toLocaleString("de-DE")} Tage`;
|
||||
}
|
||||
|
||||
function thaiCellTitle(member: TeamMember, date: string): string {
|
||||
return `${member.name}: ${date}`;
|
||||
}
|
||||
|
||||
interface DayState {
|
||||
kind: "bestaetigt" | "beantragt" | "krank" | "feiertag" | "wochenende" | "frei";
|
||||
title: string;
|
||||
}
|
||||
|
||||
function dayState(
|
||||
member: TeamMember,
|
||||
date: string,
|
||||
day: number,
|
||||
idSuffix: string,
|
||||
): DayState {
|
||||
const request =
|
||||
member.requests.find(
|
||||
(item) => item.startDate <= date && date <= item.endDate,
|
||||
) ?? null;
|
||||
const open =
|
||||
request !== null &&
|
||||
member.requests.some(
|
||||
(item) =>
|
||||
item.startDate <= date &&
|
||||
date <= item.endDate &&
|
||||
item.status === "beantragt",
|
||||
);
|
||||
const confirmed =
|
||||
request !== null &&
|
||||
member.requests.some(
|
||||
(item) =>
|
||||
item.startDate <= date &&
|
||||
date <= item.endDate &&
|
||||
item.status === "bestätigt",
|
||||
);
|
||||
const sick = member.sickDays.some((day) => day.date === date);
|
||||
const holiday = member.holidays.includes(date);
|
||||
const target = (member.weekdayTargets?.[new Date(date).getUTCDay()] ?? 0);
|
||||
void idSuffix;
|
||||
if (sick) {
|
||||
return { kind: "krank", title: `${member.name}: krank, ${date}` };
|
||||
}
|
||||
if (open) {
|
||||
return {
|
||||
kind: "beantragt",
|
||||
title: `${member.name}: Urlaub beantragt, ${date}`,
|
||||
};
|
||||
}
|
||||
if (confirmed) {
|
||||
return { kind: "bestaetigt", title: `${member.name}: Urlaub, ${date}` };
|
||||
}
|
||||
if (holiday) {
|
||||
return { kind: "feiertag", title: `${member.name}: Feiertag, ${date}` };
|
||||
}
|
||||
if (target === 0) {
|
||||
void thaiCellTitle;
|
||||
return { kind: "wochenende", title: `${member.name}: Wochenende, ${date}` };
|
||||
}
|
||||
return { kind: "frei", title: thaiCellTitle(member, date) };
|
||||
}
|
||||
|
||||
function monthMatrix(year: number, month: number): string[] {
|
||||
const days = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
return Array.from(
|
||||
{ length: days },
|
||||
(_, index) =>
|
||||
`${year}-${String(month).padStart(2, "0")}-${String(index + 1).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamTab({
|
||||
year,
|
||||
active,
|
||||
onReloaded,
|
||||
initialMonth,
|
||||
}: {
|
||||
year: number;
|
||||
active: boolean;
|
||||
onReloaded?: () => void;
|
||||
initialMonth?: number;
|
||||
}) {
|
||||
const [team, setTeam] = useState<TeamMember[] | null>(null);
|
||||
const [month, setMonth] = useState(initialMonth ?? new Date().getUTCMonth() + 1);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [deciding, setDeciding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const data = await getTeam(year);
|
||||
setTeam(data.team);
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
}, [year]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setTeam(null);
|
||||
setError(null);
|
||||
reload();
|
||||
}, [reload, active]);
|
||||
|
||||
async function decide(
|
||||
id: number,
|
||||
action: "genehmigen" | "ablehnen",
|
||||
): Promise<void> {
|
||||
setDeciding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideVacation(id, action);
|
||||
await reload();
|
||||
onReloaded?.();
|
||||
} catch (decideError) {
|
||||
setError(
|
||||
decideError instanceof Error
|
||||
? decideError.message
|
||||
: "Entscheidung fehlgeschlagen.",
|
||||
);
|
||||
} finally {
|
||||
setDeciding(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<section aria-label="Team">
|
||||
<h2>Team</h2>
|
||||
<p role="alert">Teamdaten konnten nicht geladen werden.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!team) {
|
||||
return (
|
||||
<section aria-label="Team">
|
||||
<h2>Team</h2>
|
||||
<p>Wird geladen …</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const open = team.flatMap((member) =>
|
||||
member.requests
|
||||
.filter((request) => request.status === "beantragt")
|
||||
.map((request) => ({ member, request })),
|
||||
);
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
setMonth((current) => {
|
||||
const next = current + delta;
|
||||
if (next === 0) {
|
||||
return 12;
|
||||
}
|
||||
if (next === 13) {
|
||||
return 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const days = monthMatrix(year, month);
|
||||
const unitCell = (state: DayState): string =>
|
||||
state.kind === "bestaetigt"
|
||||
? "1"
|
||||
: state.kind === "krank"
|
||||
? "1"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section aria-label="Team">
|
||||
<h2>Team</h2>
|
||||
{open.length > 0 && (
|
||||
<section aria-label="Offene Anfragen">
|
||||
<h3>Offene Anfragen ({open.length})</h3>
|
||||
<ul className="offene-liste">
|
||||
{open.map(({ member, request }) => (
|
||||
<li key={`${member.id}-${request.id}`}>
|
||||
<span>
|
||||
{member.name}: {shortGermanDate(request.startDate)}
|
||||
{request.startDate !== request.endDate
|
||||
? ` – ${shortGermanDate(request.endDate)}`
|
||||
: ""}{" "}
|
||||
({pluralDays(request.units)})
|
||||
</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={deciding}
|
||||
aria-label={`Genehmigen (${member.name}, ${shortGermanDate(request.startDate)}, ${pluralDays(request.units)})`}
|
||||
onClick={() => decide(request.id, "genehmigen")}
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
disabled={deciding}
|
||||
aria-label={`Ablehnen (${member.name}, ${shortGermanDate(request.startDate)}, ${pluralDays(request.units)})`}
|
||||
onClick={() => decide(request.id, "ablehnen")}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="team-nav">
|
||||
<button type="button" onClick={() => changeMonth(-1)}>
|
||||
‹
|
||||
</button>
|
||||
<strong>{monthNames[month - 1]}</strong>
|
||||
<button type="button" onClick={() => changeMonth(1)}>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="team-matrix-wrap">
|
||||
<table className="team-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Benutzer</th>
|
||||
<th scope="col">Rest</th>
|
||||
{days.map((date) => (
|
||||
<th scope="col" key={date}>
|
||||
{Number(date.slice(8, 10))}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{team.map((member) => (
|
||||
<tr key={member.id}>
|
||||
<th scope="row">
|
||||
{member.name}
|
||||
<span className="team-rest">{pluralDays(member.remainingUnits)}</span>
|
||||
</th>
|
||||
{days.map((date) => {
|
||||
const state = dayState(member, date, Number(date.slice(8, 10)), member.id);
|
||||
return (
|
||||
<td key={date}>
|
||||
<span
|
||||
className={`tcell tcell-${state.kind}`}
|
||||
title={state.title}
|
||||
>
|
||||
{state.kind === "frei" ? "" : unitSymbol(state.kind)}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
<tr aria-label="Urlaub">
|
||||
<th scope="row">Urlaub</th>
|
||||
{summaryRow(team, days, "urlaub")}
|
||||
</tr>
|
||||
<tr aria-label="Beantragt">
|
||||
<th scope="row">Beantragt</th>
|
||||
{summaryRow(team, days, "beantragt")}
|
||||
</tr>
|
||||
<tr aria-label="Krank">
|
||||
<th scope="row">Krank</th>
|
||||
{summaryRow(team, days, "krank")}
|
||||
</tr>
|
||||
<tr aria-label="Anwesend">
|
||||
<th scope="row">Anwesend</th>
|
||||
{summaryRow(team, days, "anwesend")}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const monthNames = [
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
];
|
||||
|
||||
function unitSymbol(kind: string): string {
|
||||
return kind === "bestaetigt"
|
||||
? "U"
|
||||
: kind === "krank"
|
||||
? "K"
|
||||
: kind === "beantragt"
|
||||
? "B"
|
||||
: kind === "feiertag"
|
||||
? "F"
|
||||
: "–";
|
||||
}
|
||||
|
||||
function summaryRow(team: TeamMember[], days: string[], kind: string) {
|
||||
return days.map((date) => {
|
||||
let count = 0;
|
||||
for (const member of team) {
|
||||
const state = dayState(member, date, Number(date.slice(8, 10)), member.id);
|
||||
if (
|
||||
(kind === "urlaub" && state.kind === "bestaetigt") ||
|
||||
(kind === "beantragt" && state.kind === "beantragt") ||
|
||||
(kind === "krank" && state.kind === "krank")
|
||||
) {
|
||||
count += 1;
|
||||
} else if (kind === "anwesend" && state.kind === "frei") {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<td
|
||||
key={date}
|
||||
className="tcell-summary"
|
||||
data-summary={`${kind}-${date}`}
|
||||
>
|
||||
{count > 0 ? count : ""}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user