- Fristen page with list view (sortable, filterable by status/case) - Calendar view with month navigation and deadline dots - Deadline calculator page (proceeding type + trigger date = timeline) - Traffic light urgency: red (overdue), amber (this week), green (OK) - Backend: GET /api/deadlines (all tenant deadlines), GET /api/proceeding-types - API client: added patch() method - Types: DeadlineRule, ProceedingType, CalculatedDeadline, RuleTreeNode
155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import type { Deadline } from "@/lib/types";
|
|
import {
|
|
format,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
eachDayOfInterval,
|
|
isSameMonth,
|
|
isToday,
|
|
parseISO,
|
|
isPast,
|
|
isThisWeek,
|
|
addMonths,
|
|
subMonths,
|
|
} from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { useState, useMemo } from "react";
|
|
|
|
interface DeadlineCalendarViewProps {
|
|
deadlines: Deadline[];
|
|
}
|
|
|
|
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
|
|
if (deadline.status === "completed") return "green";
|
|
const due = parseISO(deadline.due_date);
|
|
if (isPast(due)) return "red";
|
|
if (isThisWeek(due, { weekStartsOn: 1 })) return "amber";
|
|
return "green";
|
|
}
|
|
|
|
const dotColors = {
|
|
red: "bg-red-500",
|
|
amber: "bg-amber-500",
|
|
green: "bg-green-500",
|
|
};
|
|
|
|
export function DeadlineCalendarView({ deadlines }: DeadlineCalendarViewProps) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
|
|
const monthStart = startOfMonth(currentMonth);
|
|
const monthEnd = endOfMonth(currentMonth);
|
|
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
|
const days = eachDayOfInterval({ start: calStart, end: calEnd });
|
|
|
|
const deadlinesByDay = useMemo(() => {
|
|
const map = new Map<string, Deadline[]>();
|
|
for (const d of deadlines) {
|
|
if (d.status === "completed") continue;
|
|
const key = d.due_date.slice(0, 10);
|
|
const existing = map.get(key) || [];
|
|
existing.push(d);
|
|
map.set(key, existing);
|
|
}
|
|
return map;
|
|
}, [deadlines]);
|
|
|
|
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
|
|
|
return (
|
|
<div className="rounded-lg border border-neutral-200 bg-white">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
|
<button
|
|
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<span className="text-sm font-medium text-neutral-900">
|
|
{format(currentMonth, "MMMM yyyy", { locale: de })}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday labels */}
|
|
<div className="grid grid-cols-7 border-b border-neutral-100">
|
|
{weekDays.map((d) => (
|
|
<div key={d} className="px-2 py-2 text-center text-xs font-medium text-neutral-400">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Days grid */}
|
|
<div className="grid grid-cols-7">
|
|
{days.map((day, i) => {
|
|
const key = format(day, "yyyy-MM-dd");
|
|
const dayDeadlines = deadlinesByDay.get(key) || [];
|
|
const inMonth = isSameMonth(day, currentMonth);
|
|
const today = isToday(day);
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`min-h-[4.5rem] border-b border-r border-neutral-100 p-1.5 ${
|
|
!inMonth ? "bg-neutral-50" : ""
|
|
}`}
|
|
>
|
|
<div
|
|
className={`mb-1 text-right text-xs ${
|
|
today
|
|
? "font-bold text-neutral-900"
|
|
: inMonth
|
|
? "text-neutral-600"
|
|
: "text-neutral-300"
|
|
}`}
|
|
>
|
|
{today ? (
|
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-neutral-900 text-white">
|
|
{format(day, "d")}
|
|
</span>
|
|
) : (
|
|
format(day, "d")
|
|
)}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{dayDeadlines.slice(0, 3).map((dl) => {
|
|
const urgency = getUrgency(dl);
|
|
return (
|
|
<div
|
|
key={dl.id}
|
|
className="flex items-center gap-1 truncate"
|
|
title={dl.title}
|
|
>
|
|
<div className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotColors[urgency]}`} />
|
|
<span className="truncate text-[10px] text-neutral-700">
|
|
{dl.title}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{dayDeadlines.length > 3 && (
|
|
<div className="text-[10px] text-neutral-400">
|
|
+{dayDeadlines.length - 3} mehr
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|