feat: add password recovery and everyday workflow improvements

This commit is contained in:
t.indorf
2026-10-12 17:51:50 +02:00
parent 061024d901
commit 661776e609
19 changed files with 1721 additions and 141 deletions
+138 -34
View File
@@ -4,6 +4,7 @@ import { useId, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Dialog,
@@ -32,6 +33,7 @@ export function Field({
min,
max,
step,
autoFocus,
}: {
label: string;
name: string;
@@ -41,6 +43,7 @@ export function Field({
min?: string | number;
max?: string | number;
step?: string | number;
autoFocus?: boolean;
}) {
const id = useId();
return (
@@ -57,6 +60,7 @@ export function Field({
min={min}
max={max}
step={step}
autoFocus={autoFocus}
/>
</div>
);
@@ -123,7 +127,11 @@ function transform(command: string, form: FormData) {
return {
kind: text("kind"),
startDate: text("startDate"),
endDate: text("endDate"),
endDate:
text("kind") === "TIME_OFF" ||
(text("kind") === "VACATION" && form.get("halfDay") === "on")
? text("startDate")
: text("endDate"),
halfDay: form.get("halfDay") === "on",
...(text("kind") === "TIME_OFF" ? { minutes: hours("hours") } : {}),
note: text("note"),
@@ -142,13 +150,19 @@ function StaffForm({
children,
label = "Speichern",
onSuccess,
anotherLabel,
}: {
command: string;
profileId?: string;
extra?: Record<string, unknown>;
children: ReactNode;
label?: string;
onSuccess?: (result: StaffActionResult) => void;
anotherLabel?: string;
onSuccess?: (
result: StaffActionResult,
form: FormData,
another: boolean,
) => void;
}) {
const [pending, start] = useTransition();
const [error, setError] = useState("");
@@ -159,6 +173,10 @@ function StaffForm({
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const another =
(event.nativeEvent as SubmitEvent).submitter?.getAttribute(
"value",
) === "another";
setError("");
start(async () => {
try {
@@ -173,7 +191,7 @@ function StaffForm({
}
if (result.warning) toast.warning(result.warning);
else toast.success(result.message ?? "Gespeichert.");
onSuccess?.(result);
onSuccess?.(result, form, another);
router.refresh();
} catch (error) {
setError(
@@ -187,10 +205,20 @@ function StaffForm({
>
<fieldset disabled={pending} className="grid gap-4 disabled:opacity-60">
{children}
<div>
<div className="flex flex-wrap gap-2">
<Button type="submit">
{pending ? "Wird gespeichert …" : label}
</Button>
{anotherLabel ? (
<Button
type="submit"
variant="outline"
name="intent"
value="another"
>
{anotherLabel}
</Button>
) : null}
</div>
</fieldset>
{error && (
@@ -333,8 +361,12 @@ export function SetupForm({
export function TimeForm({
profileId,
initial,
defaultDate,
compact = false,
}: {
profileId: string;
defaultDate?: string;
compact?: boolean;
initial?: {
id: string;
date: string;
@@ -345,14 +377,34 @@ export function TimeForm({
};
}) {
const [open, setOpen] = useState(false);
const [date, setDate] = useState(initial?.date ?? defaultDate ?? todayKey());
const [blockVersion, setBlockVersion] = useState(0);
const [continuation, setContinuation] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setDate(initial?.date ?? defaultDate ?? todayKey());
setContinuation(false);
}
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button
variant={initial ? "outline" : "default"}
size={initial ? "sm" : "default"}
variant={initial || compact ? "outline" : "default"}
size={initial || compact ? "sm" : "default"}
disabled={!initial && !!defaultDate && defaultDate > todayKey()}
aria-label={
compact ? `Arbeitszeit für ${defaultDate} erfassen` : undefined
}
>
{initial ? "Bearbeiten" : "+ Arbeitszeit erfassen"}
{initial
? "Bearbeiten"
: compact
? "Zeit erfassen"
: "+ Arbeitszeit erfassen"}
</Button>
</DialogTrigger>
<DialogContent>
@@ -366,31 +418,50 @@ export function TimeForm({
</DialogDescription>
</DialogHeader>
<StaffForm
key={blockVersion}
command="time"
profileId={profileId}
extra={initial ? { id: initial.id } : {}}
label="Arbeitszeit speichern"
onSuccess={() => setOpen(false)}
anotherLabel={
!initial ? "Speichern und weiteren Block erfassen" : undefined
}
onSuccess={(_result, form, another) => {
if (another && !initial) {
setDate(String(form.get("date")));
setContinuation(true);
setBlockVersion((version) => version + 1);
} else setOpen(false);
}}
>
<Field
label="Datum"
name="date"
type="date"
value={initial?.date ?? todayKey()}
value={date}
max={todayKey()}
/>
<div className="grid grid-cols-2 gap-4">
<Field
label="Beginn"
autoFocus={continuation}
name="start"
type="time"
value={minuteClock(initial?.startMinute ?? 480)}
value={
continuation
? undefined
: minuteClock(initial?.startMinute ?? 480)
}
/>
<Field
label="Ende"
name="end"
type="time"
value={minuteClock(initial?.endMinute ?? 960)}
value={
continuation
? undefined
: minuteClock(initial?.endMinute ?? 960)
}
/>
</div>
<Field
@@ -399,7 +470,7 @@ export function TimeForm({
type="number"
min={0}
step={1}
value={initial?.breakMinutes ?? 30}
value={continuation ? 0 : (initial?.breakMinutes ?? 30)}
/>
<Field
label="Notiz (optional)"
@@ -431,9 +502,24 @@ export function LeaveForm({
}) {
const [open, setOpen] = useState(false);
const [kind, setKind] = useState(initial?.kind ?? "VACATION");
const [startDate, setStartDate] = useState(initial?.startDate ?? todayKey());
const [endDate, setEndDate] = useState(initial?.endDate ?? todayKey());
const [halfDay, setHalfDay] = useState(initial?.halfDay ?? false);
const singleDay = kind === "TIME_OFF" || (kind === "VACATION" && halfDay);
const id = useId();
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
if (next) {
setKind(initial?.kind ?? "VACATION");
setStartDate(initial?.startDate ?? todayKey());
setEndDate(initial?.endDate ?? todayKey());
setHalfDay(initial?.halfDay ?? false);
}
setOpen(next);
}}
>
<DialogTrigger asChild>
<Button size={initial ? "sm" : "default"} variant="outline">
{initial ? "Ändern" : "+ Abwesenheit beantragen"}
@@ -474,33 +560,51 @@ export function LeaveForm({
{admin && <option value="SICKNESS">Krankheit</option>}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label="Von"
name="startDate"
type="date"
value={initial?.startDate ?? todayKey()}
/>
<Field
label="Bis"
name="endDate"
type="date"
value={initial?.endDate ?? todayKey()}
/>
</div>
{kind === "VACATION" && (
{kind === "VACATION" ? (
<label className="flex items-center gap-2 text-sm">
<input
name="halfDay"
type="checkbox"
defaultChecked={initial?.halfDay}
/>
Halber Urlaubstag (Von = Bis)
checked={halfDay}
onChange={(event) => setHalfDay(event.target.checked)}
/>{" "}
Halber Urlaubstag
</label>
)}
) : null}
<div className={singleDay ? "grid gap-4" : "grid grid-cols-2 gap-4"}>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-start`}>
{singleDay ? "Datum" : "Von"}
</Label>
<Input
id={`${id}-start`}
name="startDate"
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
required
/>
</div>
{singleDay ? (
<input type="hidden" name="endDate" value={startDate} />
) : (
<div className="grid gap-1.5">
<Label htmlFor={`${id}-end`}>Bis</Label>
<Input
id={`${id}-end`}
name="endDate"
type="date"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
required
min={startDate}
/>
</div>
)}
</div>
{kind === "TIME_OFF" && (
<Field
label="Freizeitausgleich in Stunden (Von = Bis)"
label="Freizeitausgleich in Stunden"
name="hours"
type="number"
min={1 / 60}
+22 -1
View File
@@ -284,7 +284,17 @@ export default async function PersonalPage({
</div>
{profile && (
<div className="flex flex-wrap gap-2">
<TimeForm profileId={profile.id} />
<TimeForm
key={`${profile.id}-${start}`}
profileId={profile.id}
defaultDate={
tab === "zeit"
? today >= start && today <= end
? today
: start
: today
}
/>
<LeaveForm profileId={profile.id} admin={admin} />
</div>
)}
@@ -645,6 +655,7 @@ export default async function PersonalPage({
"Ist",
"Urlaub / Krankheit",
"Differenz",
"Erfassen",
].map((s) => (
<th key={s} className="pb-3 pr-4 font-medium">
{s}
@@ -682,6 +693,15 @@ export default async function PersonalPage({
>
{formatMinutes(d.balance)}
</td>
<td className="py-3 pl-3">
{editable && d.date <= today ? (
<TimeForm
profileId={profile.id}
defaultDate={d.date}
compact
/>
) : null}
</td>
</tr>
))}
</tbody>
@@ -696,6 +716,7 @@ export default async function PersonalPage({
<td className="pt-4">
{formatMinutes(totals.balance)}
</td>
<td />
</tr>
</tfoot>
</table>