fix: add array guards to all frontend components consuming API responses

Prevents "M.forEach is not a function" crashes when API returns error
objects or unexpected shapes instead of arrays. Guards all useQuery
consumers with Array.isArray checks and safe defaults for object props.

Files fixed: DeadlineList, AppointmentList, TenantSwitcher,
DeadlineTrafficLights, UpcomingTimeline, CaseOverviewGrid,
AISummaryCard, TeamSettings, and all page-level components
(dashboard, cases, fristen, termine, ai/extract).
This commit is contained in:
m
2026-03-25 18:34:11 +01:00
parent e635efa71e
commit 50bfa3deb4
14 changed files with 42 additions and 33 deletions

View File

@@ -27,7 +27,7 @@ export default function AIExtractPage() {
queryFn: () => api.get<PaginatedResponse<Case>>("/cases"), queryFn: () => api.get<PaginatedResponse<Case>>("/cases"),
}); });
const cases = casesData?.data ?? []; const cases = Array.isArray(casesData?.data) ? casesData.data : [];
async function handleExtract(file: File | null, text: string) { async function handleExtract(file: File | null, text: string) {
setIsExtracting(true); setIsExtracting(true);

View File

@@ -132,8 +132,8 @@ export default function CaseDetailPage() {
); );
} }
const deadlines = deadlinesData?.deadlines ?? []; const deadlines = Array.isArray(deadlinesData?.deadlines) ? deadlinesData.deadlines : [];
const documents = documentsData ?? []; const documents = Array.isArray(documentsData) ? documentsData : [];
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -205,7 +205,7 @@ export default function CaseDetailPage() {
{caseDetail.deadlines_count} {caseDetail.deadlines_count}
</span> </span>
)} )}
{tab.key === "parties" && caseDetail.parties.length > 0 && ( {tab.key === "parties" && Array.isArray(caseDetail.parties) && caseDetail.parties.length > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500"> <span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{caseDetail.parties.length} {caseDetail.parties.length}
</span> </span>
@@ -217,7 +217,7 @@ export default function CaseDetailPage() {
<div className="mt-6"> <div className="mt-6">
{activeTab === "timeline" && ( {activeTab === "timeline" && (
<CaseTimeline events={caseDetail.recent_events ?? []} /> <CaseTimeline events={Array.isArray(caseDetail.recent_events) ? caseDetail.recent_events : []} />
)} )}
{activeTab === "deadlines" && ( {activeTab === "deadlines" && (
@@ -229,7 +229,7 @@ export default function CaseDetailPage() {
)} )}
{activeTab === "parties" && ( {activeTab === "parties" && (
<PartyList caseId={id} parties={caseDetail.parties ?? []} /> <PartyList caseId={id} parties={Array.isArray(caseDetail.parties) ? caseDetail.parties : []} />
)} )}
</div> </div>
</div> </div>

View File

@@ -68,7 +68,7 @@ export default function CasesPage() {
}, },
}); });
const cases = data?.cases ?? []; const cases = Array.isArray(data?.cases) ? data.cases : [];
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">

View File

@@ -80,17 +80,17 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
<DeadlineTrafficLights data={data.deadline_summary} /> <DeadlineTrafficLights data={data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 }} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<UpcomingTimeline <UpcomingTimeline
deadlines={data.upcoming_deadlines} deadlines={Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : []}
appointments={data.upcoming_appointments} appointments={Array.isArray(data.upcoming_appointments) ? data.upcoming_appointments : []}
/> />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<CaseOverviewGrid data={data.case_summary} /> <CaseOverviewGrid data={data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 }} />
<AISummaryCard data={data} /> <AISummaryCard data={data} />
<QuickActions /> <QuickActions />
</div> </div>

View File

@@ -66,7 +66,7 @@ export default function FristenPage() {
{view === "list" ? ( {view === "list" ? (
<DeadlineList /> <DeadlineList />
) : ( ) : (
<DeadlineCalendarView deadlines={deadlines || []} /> <DeadlineCalendarView deadlines={Array.isArray(deadlines) ? deadlines : []} />
)} )}
</div> </div>
); );

View File

@@ -84,7 +84,7 @@ export default function TerminePage() {
<AppointmentList onEdit={handleEdit} /> <AppointmentList onEdit={handleEdit} />
) : ( ) : (
<AppointmentCalendar <AppointmentCalendar
appointments={appointments || []} appointments={Array.isArray(appointments) ? appointments : []}
onAppointmentClick={handleEdit} onAppointmentClick={handleEdit}
/> />
)} )}

View File

@@ -73,12 +73,13 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
const caseMap = useMemo(() => { const caseMap = useMemo(() => {
const map = new Map<string, Case>(); const map = new Map<string, Case>();
cases?.cases?.forEach((c) => map.set(c.id, c)); const arr = Array.isArray(cases?.cases) ? cases.cases : [];
arr.forEach((c) => map.set(c.id, c));
return map; return map;
}, [cases]); }, [cases]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!appointments) return []; if (!Array.isArray(appointments)) return [];
return appointments return appointments
.filter((a) => { .filter((a) => {
if (caseFilter !== "all" && a.case_id !== caseFilter) return false; if (caseFilter !== "all" && a.case_id !== caseFilter) return false;
@@ -91,7 +92,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
const grouped = useMemo(() => groupByDate(filtered), [filtered]); const grouped = useMemo(() => groupByDate(filtered), [filtered]);
const counts = useMemo(() => { const counts = useMemo(() => {
if (!appointments) return { today: 0, thisWeek: 0, total: 0 }; if (!Array.isArray(appointments)) return { today: 0, thisWeek: 0, total: 0 };
let today = 0; let today = 0;
let thisWeek = 0; let thisWeek = 0;
for (const a of appointments) { for (const a of appointments) {
@@ -148,7 +149,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
</option> </option>
))} ))}
</select> </select>
{cases?.cases && cases.cases.length > 0 && ( {Array.isArray(cases?.cases) && cases.cases.length > 0 && (
<select <select
value={caseFilter} value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)} onChange={(e) => setCaseFilter(e.target.value)}

View File

@@ -9,7 +9,9 @@ interface Props {
function generateSummary(data: DashboardData): string { function generateSummary(data: DashboardData): string {
const parts: string[] = []; const parts: string[] = [];
const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data; const ds = data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
const cs = data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
const ud = Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : [];
// Deadline urgency // Deadline urgency
if (ds.overdue_count > 0) { if (ds.overdue_count > 0) {

View File

@@ -8,24 +8,25 @@ interface Props {
} }
export function CaseOverviewGrid({ data }: Props) { export function CaseOverviewGrid({ data }: Props) {
const safe = data ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
const items = [ const items = [
{ {
label: "Aktive Akten", label: "Aktive Akten",
value: data.active_count, value: safe.active_count ?? 0,
icon: FolderOpen, icon: FolderOpen,
color: "text-blue-600", color: "text-blue-600",
bg: "bg-blue-50", bg: "bg-blue-50",
}, },
{ {
label: "Neu (Monat)", label: "Neu (Monat)",
value: data.new_this_month, value: safe.new_this_month ?? 0,
icon: FolderPlus, icon: FolderPlus,
color: "text-violet-600", color: "text-violet-600",
bg: "bg-violet-50", bg: "bg-violet-50",
}, },
{ {
label: "Abgeschlossen", label: "Abgeschlossen",
value: data.closed_count, value: safe.closed_count ?? 0,
icon: Archive, icon: Archive,
color: "text-neutral-500", color: "text-neutral-500",
bg: "bg-neutral-50", bg: "bg-neutral-50",

View File

@@ -31,24 +31,25 @@ interface Props {
} }
export function DeadlineTrafficLights({ data, onFilter }: Props) { export function DeadlineTrafficLights({ data, onFilter }: Props) {
const safe = data ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
const cards = [ const cards = [
{ {
key: "overdue" as const, key: "overdue" as const,
label: "Überfällig", label: "Überfällig",
count: data.overdue_count, count: safe.overdue_count ?? 0,
icon: AlertTriangle, icon: AlertTriangle,
bg: "bg-red-50", bg: "bg-red-50",
border: "border-red-200", border: "border-red-200",
iconColor: "text-red-500", iconColor: "text-red-500",
countColor: "text-red-700", countColor: "text-red-700",
labelColor: "text-red-600", labelColor: "text-red-600",
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "", ring: (safe.overdue_count ?? 0) > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
pulse: data.overdue_count > 0, pulse: (safe.overdue_count ?? 0) > 0,
}, },
{ {
key: "this_week" as const, key: "this_week" as const,
label: "Diese Woche", label: "Diese Woche",
count: data.due_this_week, count: safe.due_this_week ?? 0,
icon: Clock, icon: Clock,
bg: "bg-amber-50", bg: "bg-amber-50",
border: "border-amber-200", border: "border-amber-200",
@@ -61,7 +62,7 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
{ {
key: "ok" as const, key: "ok" as const,
label: "Im Zeitplan", label: "Im Zeitplan",
count: data.ok_count + data.due_next_week, count: (safe.ok_count ?? 0) + (safe.due_next_week ?? 0),
icon: CheckCircle, icon: CheckCircle,
bg: "bg-emerald-50", bg: "bg-emerald-50",
border: "border-emerald-200", border: "border-emerald-200",

View File

@@ -21,13 +21,16 @@ function formatDayLabel(date: Date): string {
} }
export function UpcomingTimeline({ deadlines, appointments }: Props) { export function UpcomingTimeline({ deadlines, appointments }: Props) {
const safeDeadlines = Array.isArray(deadlines) ? deadlines : [];
const safeAppointments = Array.isArray(appointments) ? appointments : [];
const items: TimelineItem[] = [ const items: TimelineItem[] = [
...deadlines.map((d) => ({ ...safeDeadlines.map((d) => ({
type: "deadline" as const, type: "deadline" as const,
date: parseISO(d.due_date), date: parseISO(d.due_date),
data: d, data: d,
})), })),
...appointments.map((a) => ({ ...safeAppointments.map((a) => ({
type: "appointment" as const, type: "appointment" as const,
date: parseISO(a.start_at), date: parseISO(a.start_at),
data: a, data: a,

View File

@@ -76,12 +76,12 @@ export function DeadlineList() {
const caseMap = useMemo(() => { const caseMap = useMemo(() => {
const map = new Map<string, Case>(); const map = new Map<string, Case>();
cases?.forEach((c) => map.set(c.id, c)); (Array.isArray(cases) ? cases : []).forEach((c) => map.set(c.id, c));
return map; return map;
}, [cases]); }, [cases]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!deadlines) return []; if (!Array.isArray(deadlines)) return [];
return deadlines.filter((d) => { return deadlines.filter((d) => {
if (statusFilter === "pending" && d.status !== "pending") return false; if (statusFilter === "pending" && d.status !== "pending") return false;
if (statusFilter === "completed" && d.status !== "completed") if (statusFilter === "completed" && d.status !== "completed")
@@ -96,7 +96,7 @@ export function DeadlineList() {
}, [deadlines, statusFilter, caseFilter]); }, [deadlines, statusFilter, caseFilter]);
const counts = useMemo(() => { const counts = useMemo(() => {
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 }; if (!Array.isArray(deadlines)) return { overdue: 0, thisWeek: 0, ok: 0 };
let overdue = 0, let overdue = 0,
thisWeek = 0, thisWeek = 0,
ok = 0; ok = 0;
@@ -188,7 +188,7 @@ export function DeadlineList() {
<option value="completed">Erledigt</option> <option value="completed">Erledigt</option>
<option value="overdue">Überfällig</option> <option value="overdue">Überfällig</option>
</select> </select>
{cases && cases.length > 0 && ( {Array.isArray(cases) && cases.length > 0 && (
<select <select
value={caseFilter} value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)} onChange={(e) => setCaseFilter(e.target.value)}

View File

@@ -15,6 +15,7 @@ export function TenantSwitcher() {
api api
.get<TenantWithRole[]>("/tenants") .get<TenantWithRole[]>("/tenants")
.then((data) => { .then((data) => {
if (!Array.isArray(data)) return;
setTenants(data); setTenants(data);
const savedId = localStorage.getItem("kanzlai_tenant_id"); const savedId = localStorage.getItem("kanzlai_tenant_id");
const match = data.find((t) => t.id === savedId) || data[0]; const match = data.find((t) => t.id === savedId) || data[0];

View File

@@ -118,7 +118,7 @@ export function TeamSettings() {
</form> </form>
{/* Members List */} {/* Members List */}
{members && members.length > 0 ? ( {Array.isArray(members) && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200"> <div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => { {members.map((member, i) => {
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member; const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;