add features

This commit is contained in:
Matthias Hochmeister
2026-02-27 19:50:14 +01:00
parent c5e8337a69
commit 620bacc6b5
46 changed files with 14095 additions and 1 deletions

View File

@@ -0,0 +1,299 @@
import { api } from './api';
// ---------------------------------------------------------------------------
// SHARED TYPES (mirrors backend models, kept lean for frontend consumption)
// ---------------------------------------------------------------------------
export const EINSATZ_ARTEN = [
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache',
] as const;
export type EinsatzArt = (typeof EINSATZ_ARTEN)[number];
export const EINSATZ_ART_LABELS: Record<EinsatzArt, string> = {
Brand: 'Brand',
THL: 'Technische Hilfeleistung',
ABC: 'ABC / Gefahrgut',
BMA: 'Brandmeldeanlage',
Hilfeleistung: 'Hilfeleistung',
Fehlalarm: 'Fehlalarm',
Brandsicherheitswache: 'Brandsicherheitswache',
};
export const EINSATZ_STATUS = ['aktiv', 'abgeschlossen', 'archiviert'] as const;
export type EinsatzStatus = (typeof EINSATZ_STATUS)[number];
export const EINSATZ_STATUS_LABELS: Record<EinsatzStatus, string> = {
aktiv: 'Aktiv',
abgeschlossen: 'Abgeschlossen',
archiviert: 'Archiviert',
};
export const EINSATZ_FUNKTIONEN = [
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges',
] as const;
export type EinsatzFunktion = (typeof EINSATZ_FUNKTIONEN)[number];
// ---------------------------------------------------------------------------
// API RESPONSE SHAPES
// ---------------------------------------------------------------------------
export interface EinsatzListItem {
id: string;
einsatz_nr: string;
alarm_time: string; // ISO 8601 string from JSON
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
ort: string | null;
strasse: string | null;
status: EinsatzStatus;
einsatzleiter_name: string | null;
hilfsfrist_min: number | null;
dauer_min: number | null;
personal_count: number;
}
export interface EinsatzPersonal {
einsatz_id: string;
user_id: string;
funktion: EinsatzFunktion;
alarm_time: string | null;
ankunft_time: string | null;
assigned_at: string;
name: string | null;
email: string;
given_name: string | null;
family_name: string | null;
}
export interface EinsatzFahrzeug {
einsatz_id: string;
fahrzeug_id: string;
ausrueck_time: string | null;
einrueck_time: string | null;
assigned_at: string;
kennzeichen: string;
bezeichnung: string;
fahrzeug_typ: string | null;
}
export interface EinsatzDetail {
id: string;
einsatz_nr: string;
alarm_time: string;
ausrueck_time: string | null;
ankunft_time: string | null;
einrueck_time: string | null;
einsatz_art: EinsatzArt;
einsatz_stichwort: string | null;
strasse: string | null;
hausnummer: string | null;
ort: string | null;
bericht_kurz: string | null;
bericht_text: string | null; // undefined/null for roles below Kommandant
einsatzleiter_id: string | null;
einsatzleiter_name: string | null;
alarmierung_art: string;
status: EinsatzStatus;
created_by: string | null;
created_at: string;
updated_at: string;
hilfsfrist_min: number | null;
dauer_min: number | null;
fahrzeuge: EinsatzFahrzeug[];
personal: EinsatzPersonal[];
}
export interface MonthlyStatRow {
monat: number;
anzahl: number;
avg_hilfsfrist_min: number | null;
avg_dauer_min: number | null;
}
export interface EinsatzArtStatRow {
einsatz_art: EinsatzArt;
anzahl: number;
avg_hilfsfrist_min: number | null;
}
export interface EinsatzStats {
jahr: number;
gesamt: number;
abgeschlossen: number;
aktiv: number;
avg_hilfsfrist_min: number | null;
haeufigste_art: EinsatzArt | null;
monthly: MonthlyStatRow[];
by_art: EinsatzArtStatRow[];
prev_year_monthly: MonthlyStatRow[];
}
export interface IncidentListResponse {
items: EinsatzListItem[];
total: number;
limit: number;
offset: number;
}
// ---------------------------------------------------------------------------
// REQUEST SHAPES
// ---------------------------------------------------------------------------
export interface IncidentFilters {
dateFrom?: string;
dateTo?: string;
einsatzArt?: EinsatzArt;
status?: EinsatzStatus;
limit?: number;
offset?: number;
}
export interface CreateEinsatzPayload {
alarm_time: string;
ausrueck_time?: string | null;
ankunft_time?: string | null;
einrueck_time?: string | null;
einsatz_art: EinsatzArt;
einsatz_stichwort?: string | null;
strasse?: string | null;
hausnummer?: string | null;
ort?: string | null;
bericht_kurz?: string | null;
bericht_text?: string | null;
einsatzleiter_id?: string | null;
alarmierung_art?: string;
status?: EinsatzStatus;
}
export type UpdateEinsatzPayload = Partial<CreateEinsatzPayload>;
export interface AssignPersonnelPayload {
user_id: string;
funktion?: EinsatzFunktion;
alarm_time?: string | null;
ankunft_time?: string | null;
}
export interface AssignVehiclePayload {
fahrzeug_id: string;
ausrueck_time?: string | null;
einrueck_time?: string | null;
}
// ---------------------------------------------------------------------------
// API CALLS
// ---------------------------------------------------------------------------
export const incidentsApi = {
/**
* Fetch paginated incident list with optional filters.
*/
async getAll(filters: IncidentFilters = {}): Promise<IncidentListResponse> {
const params = new URLSearchParams();
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
if (filters.dateTo) params.set('dateTo', filters.dateTo);
if (filters.einsatzArt) params.set('einsatzArt', filters.einsatzArt);
if (filters.status) params.set('status', filters.status);
if (filters.limit !== undefined) params.set('limit', String(filters.limit));
if (filters.offset !== undefined) params.set('offset', String(filters.offset));
const response = await api.get<{ success: boolean; data: IncidentListResponse }>(
`/api/incidents?${params.toString()}`
);
return response.data.data;
},
/**
* Fetch aggregated statistics for a given year.
*/
async getStats(year?: number): Promise<EinsatzStats> {
const params = year ? `?year=${year}` : '';
const response = await api.get<{ success: boolean; data: EinsatzStats }>(
`/api/incidents/stats${params}`
);
return response.data.data;
},
/**
* Fetch a single incident with full detail.
*/
async getById(id: string): Promise<EinsatzDetail> {
const response = await api.get<{ success: boolean; data: EinsatzDetail }>(
`/api/incidents/${id}`
);
return response.data.data;
},
/**
* Create a new incident. Returns the created Einsatz row.
*/
async create(payload: CreateEinsatzPayload): Promise<EinsatzDetail> {
const response = await api.post<{ success: boolean; data: EinsatzDetail }>(
'/api/incidents',
payload
);
return response.data.data;
},
/**
* Partially update an incident.
*/
async update(id: string, payload: UpdateEinsatzPayload): Promise<EinsatzDetail> {
const response = await api.patch<{ success: boolean; data: EinsatzDetail }>(
`/api/incidents/${id}`,
payload
);
return response.data.data;
},
/**
* Soft-delete (archive) an incident.
*/
async delete(id: string): Promise<void> {
await api.delete(`/api/incidents/${id}`);
},
/**
* Assign a member to an incident.
*/
async assignPersonnel(einsatzId: string, payload: AssignPersonnelPayload): Promise<void> {
await api.post(`/api/incidents/${einsatzId}/personnel`, payload);
},
/**
* Remove a member from an incident.
*/
async removePersonnel(einsatzId: string, userId: string): Promise<void> {
await api.delete(`/api/incidents/${einsatzId}/personnel/${userId}`);
},
/**
* Assign a vehicle to an incident.
*/
async assignVehicle(einsatzId: string, payload: AssignVehiclePayload): Promise<void> {
await api.post(`/api/incidents/${einsatzId}/vehicles`, payload);
},
/**
* Remove a vehicle from an incident.
*/
async removeVehicle(einsatzId: string, fahrzeugId: string): Promise<void> {
await api.delete(`/api/incidents/${einsatzId}/vehicles/${fahrzeugId}`);
},
};

View File

@@ -0,0 +1,113 @@
import { api } from './api';
import {
MemberListItem,
MemberWithProfile,
MemberFilters,
MemberStats,
CreateMemberProfileData,
UpdateMemberProfileData,
} from '../types/member.types';
// ----------------------------------------------------------------
// Response envelope shapes
// ----------------------------------------------------------------
interface ApiListResponse<T> {
success: boolean;
data: T[];
meta: { total: number; page: number };
}
interface ApiItemResponse<T> {
success: boolean;
data: T;
}
// ----------------------------------------------------------------
// Service
// ----------------------------------------------------------------
/**
* Builds a URLSearchParams object from the filter object so query
* strings like ?status[]=aktiv&status[]=passiv are sent correctly.
*/
function buildParams(filters?: MemberFilters): URLSearchParams {
const params = new URLSearchParams();
if (!filters) return params;
if (filters.search) params.append('search', filters.search);
if (filters.page) params.append('page', String(filters.page));
if (filters.pageSize) params.append('pageSize', String(filters.pageSize));
filters.status?.forEach((s) => params.append('status[]', s));
filters.dienstgrad?.forEach((d) => params.append('dienstgrad[]', d));
return params;
}
export const membersService = {
/**
* Fetches a paginated, optionally filtered list of members.
*/
async getMembers(
filters?: MemberFilters
): Promise<{ items: MemberListItem[]; total: number; page: number }> {
const params = buildParams(filters);
const response = await api.get<ApiListResponse<MemberListItem>>(
`/api/members?${params.toString()}`
);
return {
items: response.data.data,
total: response.data.meta.total,
page: response.data.meta.page,
};
},
/**
* Fetches a single member with their full profile and rank history.
*/
async getMember(userId: string): Promise<MemberWithProfile> {
const response = await api.get<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}`
);
return response.data.data;
},
/**
* Creates a new member profile for an existing auth user.
* Restricted to Kommandant/Admin (enforced server-side).
*/
async createMemberProfile(
userId: string,
data: CreateMemberProfileData
): Promise<MemberWithProfile> {
const response = await api.post<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}/profile`,
data
);
return response.data.data;
},
/**
* Partially updates a member profile.
* Kommandant/Admin: full update.
* Own profile: limited fields only (enforced server-side).
*/
async updateMember(
userId: string,
data: UpdateMemberProfileData
): Promise<MemberWithProfile> {
const response = await api.patch<ApiItemResponse<MemberWithProfile>>(
`/api/members/${userId}`,
data
);
return response.data.data;
},
/**
* Fetches aggregate counts for the dashboard KPI widget.
*/
async getMemberStats(): Promise<MemberStats> {
const response = await api.get<ApiItemResponse<MemberStats>>('/api/members/stats');
return response.data.data;
},
};

View File

@@ -0,0 +1,131 @@
import { api } from './api';
import { API_URL } from '../utils/config';
import type {
Uebung,
UebungWithAttendance,
UebungListItem,
MemberParticipationStats,
CreateUebungData,
UpdateUebungData,
} from '../types/training.types';
// ---------------------------------------------------------------------------
// Response shapes from the backend
// ---------------------------------------------------------------------------
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ---------------------------------------------------------------------------
// Helper: iCal subscribe URL
// ---------------------------------------------------------------------------
export function buildIcalUrl(token: string): string {
const base = API_URL.replace(/\/$/, '');
return `${base}/api/training/calendar.ics?token=${token}`;
}
// ---------------------------------------------------------------------------
// Training API service
// ---------------------------------------------------------------------------
export const trainingApi = {
// -------------------------------------------------------------------------
// Event listing
// -------------------------------------------------------------------------
/** Upcoming events (dashboard widget, list view) */
getUpcoming(limit = 10): Promise<UebungListItem[]> {
return api
.get<ApiResponse<UebungListItem[]>>('/api/training', { params: { limit } })
.then((r) => r.data.data);
},
/** Events in a date range for the month calendar view */
getCalendarRange(from: Date, to: Date): Promise<UebungListItem[]> {
return api
.get<ApiResponse<UebungListItem[]>>('/api/training/calendar', {
params: {
from: from.toISOString(),
to: to.toISOString(),
},
})
.then((r) => r.data.data);
},
/** Full event detail with attendance data */
getById(id: string): Promise<UebungWithAttendance> {
return api
.get<ApiResponse<UebungWithAttendance>>(`/api/training/${id}`)
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------
createEvent(data: CreateUebungData): Promise<Uebung> {
return api
.post<ApiResponse<Uebung>>('/api/training', data)
.then((r) => r.data.data);
},
updateEvent(id: string, data: Partial<UpdateUebungData>): Promise<Uebung> {
return api
.patch<ApiResponse<Uebung>>(`/api/training/${id}`, data)
.then((r) => r.data.data);
},
cancelEvent(id: string, absage_grund: string): Promise<void> {
return api
.delete(`/api/training/${id}`, { data: { absage_grund } })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// Attendance / RSVP
// -------------------------------------------------------------------------
/** Member updates own RSVP */
updateRsvp(
uebungId: string,
status: 'zugesagt' | 'abgesagt',
bemerkung?: string
): Promise<void> {
return api
.patch(`/api/training/${uebungId}/attendance`, { status, bemerkung })
.then(() => undefined);
},
/** Gruppenführer bulk-marks attendance */
markAttendance(uebungId: string, userIds: string[]): Promise<void> {
return api
.post(`/api/training/${uebungId}/attendance/mark`, { userIds })
.then(() => undefined);
},
// -------------------------------------------------------------------------
// Stats
// -------------------------------------------------------------------------
getMemberStats(year?: number): Promise<MemberParticipationStats[]> {
return api
.get<ApiResponse<MemberParticipationStats[]>>('/api/training/stats', {
params: { year: year ?? new Date().getFullYear() },
})
.then((r) => r.data.data);
},
// -------------------------------------------------------------------------
// iCal
// -------------------------------------------------------------------------
/** Get the user's personal calendar subscribe URL */
getCalendarToken(): Promise<{ token: string; subscribeUrl: string; instructions: string }> {
return api
.get<ApiResponse<{ token: string; subscribeUrl: string; instructions: string }>>(
'/api/training/calendar-token'
)
.then((r) => r.data.data);
},
};

View File

@@ -0,0 +1,115 @@
import { api } from './api';
import type {
FahrzeugListItem,
FahrzeugDetail,
FahrzeugPruefung,
FahrzeugWartungslog,
VehicleStats,
InspectionAlert,
CreateFahrzeugPayload,
UpdateFahrzeugPayload,
UpdateStatusPayload,
CreatePruefungPayload,
CreateWartungslogPayload,
} from '../types/vehicle.types';
// ---------------------------------------------------------------------------
// Internal: unwrap the standard { success, data } envelope
// ---------------------------------------------------------------------------
async function unwrap<T>(promise: ReturnType<typeof api.get<{ success: boolean; data: T }>>): Promise<T> {
const response = await promise;
return response.data.data;
}
// ---------------------------------------------------------------------------
// Vehicle API Service
// ---------------------------------------------------------------------------
export const vehiclesApi = {
// ── Fleet overview ──────────────────────────────────────────────────────────
/** Fetch all vehicles with their next inspection badge data */
async getAll(): Promise<FahrzeugListItem[]> {
return unwrap(api.get<{ success: boolean; data: FahrzeugListItem[] }>('/api/vehicles'));
},
/** Dashboard KPI stats */
async getStats(): Promise<VehicleStats> {
return unwrap(api.get<{ success: boolean; data: VehicleStats }>('/api/vehicles/stats'));
},
/**
* Upcoming and overdue inspection alerts.
* @param daysAhead How many days to look ahead (default 30, max 365).
*/
async getAlerts(daysAhead = 30): Promise<InspectionAlert[]> {
return unwrap(
api.get<{ success: boolean; data: InspectionAlert[] }>(
`/api/vehicles/alerts?daysAhead=${daysAhead}`
)
);
},
// ── Vehicle detail ──────────────────────────────────────────────────────────
/** Full vehicle detail including inspection history and maintenance log */
async getById(id: string): Promise<FahrzeugDetail> {
return unwrap(api.get<{ success: boolean; data: FahrzeugDetail }>(`/api/vehicles/${id}`));
},
// ── CRUD ────────────────────────────────────────────────────────────────────
async create(payload: CreateFahrzeugPayload): Promise<FahrzeugDetail> {
const response = await api.post<{ success: boolean; data: FahrzeugDetail }>(
'/api/vehicles',
payload
);
return response.data.data;
},
async update(id: string, payload: UpdateFahrzeugPayload): Promise<FahrzeugDetail> {
const response = await api.patch<{ success: boolean; data: FahrzeugDetail }>(
`/api/vehicles/${id}`,
payload
);
return response.data.data;
},
/** Live status change — Socket.IO event is emitted server-side in Tier 3 */
async updateStatus(id: string, payload: UpdateStatusPayload): Promise<void> {
await api.patch(`/api/vehicles/${id}/status`, payload);
},
// ── Inspections ─────────────────────────────────────────────────────────────
async getPruefungen(id: string): Promise<FahrzeugPruefung[]> {
return unwrap(
api.get<{ success: boolean; data: FahrzeugPruefung[] }>(`/api/vehicles/${id}/pruefungen`)
);
},
async addPruefung(id: string, payload: CreatePruefungPayload): Promise<FahrzeugPruefung> {
const response = await api.post<{ success: boolean; data: FahrzeugPruefung }>(
`/api/vehicles/${id}/pruefungen`,
payload
);
return response.data.data;
},
// ── Maintenance log ─────────────────────────────────────────────────────────
async getWartungslog(id: string): Promise<FahrzeugWartungslog[]> {
return unwrap(
api.get<{ success: boolean; data: FahrzeugWartungslog[] }>(`/api/vehicles/${id}/wartung`)
);
},
async addWartungslog(id: string, payload: CreateWartungslogPayload): Promise<FahrzeugWartungslog> {
const response = await api.post<{ success: boolean; data: FahrzeugWartungslog }>(
`/api/vehicles/${id}/wartung`,
payload
);
return response.data.data;
},
};