Add advanced planning features with new database schemas and UI

Implement new API endpoints and database schemas for guard constraints, site preferences, training courses, holidays, and absences, alongside a dedicated advanced planning UI.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 99f0fce6-9386-489a-9632-1d81223cab44
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/99f0fce6-9386-489a-9632-1d81223cab44/H8Wilyj
This commit is contained in:
marco370 2025-10-11 19:30:16 +00:00
parent 4ed700daee
commit dcbf059d73
6 changed files with 968 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import Shifts from "@/pages/shifts";
import Reports from "@/pages/reports";
import Notifications from "@/pages/notifications";
import Users from "@/pages/users";
import Planning from "@/pages/planning";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
@ -30,6 +31,7 @@ function Router() {
<Route path="/guards" component={Guards} />
<Route path="/sites" component={Sites} />
<Route path="/shifts" component={Shifts} />
<Route path="/planning" component={Planning} />
<Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} />
<Route path="/users" component={Users} />

View File

@ -8,6 +8,7 @@ import {
Settings,
LogOut,
UserCog,
ClipboardList,
} from "lucide-react";
import { Link, useLocation } from "wouter";
import {
@ -40,6 +41,12 @@ const menuItems = [
icon: Calendar,
roles: ["admin", "coordinator", "guard"],
},
{
title: "Pianificazione",
url: "/planning",
icon: ClipboardList,
roles: ["admin", "coordinator"],
},
{
title: "Guardie",
url: "/guards",

View File

@ -0,0 +1,480 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Calendar, GraduationCap, HeartPulse, PartyPopper, Plus, Trash2 } from "lucide-react";
import { format } from "date-fns";
import { it } from "date-fns/locale";
export default function PlanningPage() {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState("training");
return (
<div className="flex flex-col gap-6 p-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pianificazione Avanzata</h1>
<p className="text-muted-foreground mt-1">
Gestisci formazione, assenze e festività per ottimizzare la pianificazione turni
</p>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="training" data-testid="tab-training">
<GraduationCap className="h-4 w-4 mr-2" />
Formazione
</TabsTrigger>
<TabsTrigger value="absences" data-testid="tab-absences">
<HeartPulse className="h-4 w-4 mr-2" />
Assenze
</TabsTrigger>
<TabsTrigger value="holidays" data-testid="tab-holidays">
<PartyPopper className="h-4 w-4 mr-2" />
Festività
</TabsTrigger>
</TabsList>
<TabsContent value="training">
<TrainingTab />
</TabsContent>
<TabsContent value="absences">
<AbsencesTab />
</TabsContent>
<TabsContent value="holidays">
<HolidaysTab />
</TabsContent>
</Tabs>
</div>
);
}
function TrainingTab() {
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const { data: courses = [], isLoading } = useQuery({
queryKey: ["/api/training-courses"],
});
const createMutation = useMutation({
mutationFn: (data: any) => apiRequest("/api/training-courses", "POST", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
toast({ title: "Corso creato con successo" });
setIsCreateOpen(false);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiRequest(`/api/training-courses/${id}`, "DELETE"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
toast({ title: "Corso eliminato" });
},
});
if (isLoading) {
return <div className="text-center py-8">Caricamento corsi...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button data-testid="button-create-course">
<Plus className="h-4 w-4 mr-2" />
Nuovo Corso
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuovo Corso di Formazione</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createMutation.mutate({
guardId: formData.get("guardId"),
courseName: formData.get("courseName"),
courseType: formData.get("courseType"),
scheduledDate: formData.get("scheduledDate"),
status: "scheduled",
});
}}
>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="courseName">Nome Corso</Label>
<Input id="courseName" name="courseName" required data-testid="input-course-name" />
</div>
<div>
<Label htmlFor="courseType">Tipo</Label>
<Select name="courseType" required>
<SelectTrigger data-testid="select-course-type">
<SelectValue placeholder="Seleziona tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="mandatory">Obbligatorio</SelectItem>
<SelectItem value="optional">Facoltativo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="guardId">ID Guardia</Label>
<Input id="guardId" name="guardId" required data-testid="input-guard-id" />
</div>
<div>
<Label htmlFor="scheduledDate">Data Programmata</Label>
<Input id="scheduledDate" name="scheduledDate" type="date" required data-testid="input-scheduled-date" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-course">
{createMutation.isPending ? "Creazione..." : "Crea Corso"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{courses.map((course: any) => (
<Card key={course.id} data-testid={`card-course-${course.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{course.courseName}</CardTitle>
<CardDescription>
Programmato: {course.scheduledDate ? format(new Date(course.scheduledDate), "dd MMM yyyy", { locale: it }) : "N/D"}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<Badge variant={course.courseType === "mandatory" ? "default" : "secondary"}>
{course.courseType === "mandatory" ? "Obbligatorio" : "Facoltativo"}
</Badge>
<Badge variant={course.status === "completed" ? "default" : "secondary"}>
{course.status}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(course.id)}
data-testid={`button-delete-course-${course.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
))}
{courses.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Nessun corso di formazione programmato
</div>
)}
</div>
</div>
);
}
function AbsencesTab() {
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const { data: absences = [], isLoading } = useQuery({
queryKey: ["/api/absences"],
});
const createMutation = useMutation({
mutationFn: (data: any) => apiRequest("/api/absences", "POST", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
toast({ title: "Assenza registrata" });
setIsCreateOpen(false);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiRequest(`/api/absences/${id}`, "DELETE"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
toast({ title: "Assenza eliminata" });
},
});
if (isLoading) {
return <div className="text-center py-8">Caricamento assenze...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button data-testid="button-create-absence">
<Plus className="h-4 w-4 mr-2" />
Nuova Assenza
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Registra Assenza</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createMutation.mutate({
guardId: formData.get("guardId"),
type: formData.get("type"),
startDate: formData.get("startDate"),
endDate: formData.get("endDate"),
notes: formData.get("notes"),
isApproved: false,
needsSubstitute: true,
});
}}
>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="guardId">ID Guardia</Label>
<Input id="guardId" name="guardId" required data-testid="input-absence-guard-id" />
</div>
<div>
<Label htmlFor="type">Tipo Assenza</Label>
<Select name="type" required>
<SelectTrigger data-testid="select-absence-type">
<SelectValue placeholder="Seleziona tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sick_leave">Malattia</SelectItem>
<SelectItem value="vacation">Ferie</SelectItem>
<SelectItem value="personal_leave">Permesso</SelectItem>
<SelectItem value="injury">Infortunio</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="startDate">Data Inizio</Label>
<Input id="startDate" name="startDate" type="date" required data-testid="input-start-date" />
</div>
<div>
<Label htmlFor="endDate">Data Fine</Label>
<Input id="endDate" name="endDate" type="date" required data-testid="input-end-date" />
</div>
<div>
<Label htmlFor="notes">Note</Label>
<Input id="notes" name="notes" data-testid="input-absence-notes" />
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-absence">
{createMutation.isPending ? "Registrazione..." : "Registra Assenza"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{absences.map((absence: any) => (
<Card key={absence.id} data-testid={`card-absence-${absence.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">
{absence.type === "sick_leave" && "Malattia"}
{absence.type === "vacation" && "Ferie"}
{absence.type === "personal_leave" && "Permesso"}
{absence.type === "injury" && "Infortunio"}
</CardTitle>
<CardDescription>
Dal {format(new Date(absence.startDate), "dd MMM", { locale: it })} al{" "}
{format(new Date(absence.endDate), "dd MMM yyyy", { locale: it })}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<Badge variant={absence.isApproved ? "default" : "secondary"}>
{absence.isApproved ? "Approvata" : "In attesa"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(absence.id)}
data-testid={`button-delete-absence-${absence.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
))}
{absences.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Nessuna assenza registrata
</div>
)}
</div>
</div>
);
}
function HolidaysTab() {
const { toast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const currentYear = new Date().getFullYear();
const { data: holidays = [], isLoading } = useQuery({
queryKey: ["/api/holidays", currentYear],
queryFn: () => fetch(`/api/holidays?year=${currentYear}`).then((r) => r.json()),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiRequest("/api/holidays", "POST", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
toast({ title: "Festività creata" });
setIsCreateOpen(false);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiRequest(`/api/holidays/${id}`, "DELETE"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
toast({ title: "Festività eliminata" });
},
});
if (isLoading) {
return <div className="text-center py-8">Caricamento festività...</div>;
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button data-testid="button-create-holiday">
<Plus className="h-4 w-4 mr-2" />
Nuova Festività
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nuova Festività</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const dateValue = formData.get("date") as string;
createMutation.mutate({
name: formData.get("name"),
date: dateValue,
year: new Date(dateValue).getFullYear(),
isNational: formData.get("isNational") === "true",
});
}}
>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="name">Nome Festività</Label>
<Input id="name" name="name" required data-testid="input-holiday-name" placeholder="es. Natale, 1° Maggio..." />
</div>
<div>
<Label htmlFor="date">Data</Label>
<Input id="date" name="date" type="date" required data-testid="input-holiday-date" />
</div>
<div>
<Label htmlFor="isNational">Tipo</Label>
<Select name="isNational" defaultValue="true">
<SelectTrigger data-testid="select-holiday-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Nazionale</SelectItem>
<SelectItem value="false">Regionale</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-holiday">
{createMutation.isPending ? "Creazione..." : "Crea Festività"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{holidays.map((holiday: any) => (
<Card key={holiday.id} data-testid={`card-holiday-${holiday.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{holiday.name}</CardTitle>
<CardDescription>
{format(new Date(holiday.date), "EEEE dd MMMM yyyy", { locale: it })}
</CardDescription>
</div>
<div className="flex gap-2 items-center">
<Badge variant={holiday.isNational ? "default" : "secondary"}>
{holiday.isNational ? "Nazionale" : "Regionale"}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(holiday.id)}
data-testid={`button-delete-holiday-${holiday.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
</Card>
))}
{holidays.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
Nessuna festività configurata per {currentYear}
</div>
)}
</div>
</div>
);
}

View File

@ -70,6 +70,56 @@ Sistema professionale di gestione turni 24/7 per istituti di vigilanza con:
- id, userId (FK), title, message, type
- isRead, relatedEntityId
### Scheduling & Constraints Tables
**guard_constraints** (Vincoli Operatori):
- id, guardId (FK unique)
- preferredShiftType: morning | afternoon | night | any
- maxHoursPerDay, maxHoursPerWeek
- preferredDaysOff: array
- availableOnHolidays: boolean
**site_preferences** (Preferenze Siti):
- id, siteId (FK), guardId (FK)
- preference: preferred | blacklisted
- priority, reason
- Unique constraint: (siteId, guardId)
**contract_parameters** (Parametri CCNL):
- contractType (unique, default: CCNL_VIGILANZA_2023)
- maxHoursPerDay (8), maxOvertimePerDay (2)
- maxHoursPerWeek (40), maxOvertimePerWeek (8)
- minDailyRestHours (11), minWeeklyRestHours (24)
- maxNightHoursPerWeek (48)
- Maggiorazioni: holidayPayIncrease (30%), nightPayIncrease (20%), overtimePayIncrease (15%)
**training_courses** (Formazione):
- id, guardId (FK)
- courseName, courseType (mandatory/optional)
- scheduledDate, completionDate, expiryDate
- status: scheduled | completed | expired | cancelled
- provider, hours, certificateUrl
**holidays** (Festività):
- id, name, date, year
- isNational: boolean
- Unique constraint: (date, year)
**holiday_assignments** (Rotazioni Festività):
- id, holidayId (FK), guardId (FK), shiftId (FK nullable)
- Unique constraint: (holidayId, guardId)
**absences** (Assenze/Malattie):
- id, guardId (FK), type: sick_leave | vacation | personal_leave | injury
- startDate, endDate
- isApproved, needsSubstitute, substituteGuardId (FK nullable)
- certificateUrl, notes
**absence_affected_shifts** (Turni Impattati da Assenza):
- id, absenceId (FK), shiftId (FK)
- isSubstituted: boolean
- Unique constraint: (absenceId, shiftId)
## API Endpoints
### Authentication
@ -230,6 +280,36 @@ All interactive elements have `data-testid` attributes for automated testing.
- Toast notifiche successo/errore
- Auto-close dialog dopo aggiornamento
- Test e2e passati per tutte le pagine ✅
- **Estensione Schema Database per Pianificazione Avanzata** ✅:
- **guard_constraints**: vincoli operatori (preferenze turno, max ore, riposi, disponibilità festività)
- **site_preferences**: continuità servizio (operatori preferiti/blacklisted per sito)
- **contract_parameters**: parametri CCNL (limiti orari, riposi, maggiorazioni)
- **training_courses**: formazione obbligatoria con scadenze e tracking
- **holidays + holiday_assignments**: festività nazionali con rotazioni operatori
- **absences + absence_affected_shifts**: assenze/malattie con sistema sostituzione automatica
- Tutti unique indexes e FK integrity implementati per garantire coerenza dati
- Schema pronto per algoritmo pianificazione automatica turni
- **Pagina Pianificazione Avanzata** ✅:
- Rotta /planning accessibile a admin e coordinator
- UI con 3 tabs: Formazione, Assenze, Festività
- CRUD completo per training courses (mandatory/optional, scheduled/completed)
- CRUD completo per absences (sick_leave, vacation, personal_leave, injury)
- CRUD completo per holidays con filtraggio per anno
- Dialogs di creazione con validazione form
- Fix cache invalidation bug: TanStack Query ora invalida correttamente con parametri year
- Data formatting italiano con date-fns locale
- Toast notifications per feedback operazioni
- **API Routes Pianificazione** ✅:
- GET/POST /api/guard-constraints/:guardId - vincoli operatore (upsert)
- GET/POST/DELETE /api/site-preferences - preferenze sito-guardia
- GET/POST/PATCH/DELETE /api/training-courses - formazione (con filtro guardId)
- GET/POST/DELETE /api/holidays - festività (con filtro year)
- GET/POST/PATCH/DELETE /api/absences - assenze (con filtro guardId)
- **Storage Layer Completo** ✅:
- DatabaseStorage esteso con metodi CRUD per tutte le nuove entità
- Guard constraints: upsert semantics (create o update se esiste)
- Gestione relazioni FK con cascading deletes dove appropriato
- Query con ordering e filtering per date/status
- Aggiunto SEO completo (title, meta description, Open Graph)
- Tutti i componenti testabili con data-testid attributes

View File

@ -426,6 +426,184 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= GUARD CONSTRAINTS ROUTES =============
app.get("/api/guard-constraints/:guardId", isAuthenticated, async (req, res) => {
try {
const constraints = await storage.getGuardConstraints(req.params.guardId);
res.json(constraints || null);
} catch (error) {
console.error("Error fetching guard constraints:", error);
res.status(500).json({ message: "Failed to fetch guard constraints" });
}
});
app.post("/api/guard-constraints", isAuthenticated, async (req, res) => {
try {
const constraints = await storage.upsertGuardConstraints(req.body);
res.json(constraints);
} catch (error) {
console.error("Error upserting guard constraints:", error);
res.status(500).json({ message: "Failed to save guard constraints" });
}
});
// ============= SITE PREFERENCES ROUTES =============
app.get("/api/site-preferences/:siteId", isAuthenticated, async (req, res) => {
try {
const preferences = await storage.getSitePreferences(req.params.siteId);
res.json(preferences);
} catch (error) {
console.error("Error fetching site preferences:", error);
res.status(500).json({ message: "Failed to fetch site preferences" });
}
});
app.post("/api/site-preferences", isAuthenticated, async (req, res) => {
try {
const preference = await storage.createSitePreference(req.body);
res.json(preference);
} catch (error) {
console.error("Error creating site preference:", error);
res.status(500).json({ message: "Failed to create site preference" });
}
});
app.delete("/api/site-preferences/:id", isAuthenticated, async (req, res) => {
try {
await storage.deleteSitePreference(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting site preference:", error);
res.status(500).json({ message: "Failed to delete site preference" });
}
});
// ============= TRAINING COURSES ROUTES =============
app.get("/api/training-courses", isAuthenticated, async (req, res) => {
try {
const guardId = req.query.guardId as string | undefined;
const courses = guardId
? await storage.getTrainingCoursesByGuard(guardId)
: await storage.getAllTrainingCourses();
res.json(courses);
} catch (error) {
console.error("Error fetching training courses:", error);
res.status(500).json({ message: "Failed to fetch training courses" });
}
});
app.post("/api/training-courses", isAuthenticated, async (req, res) => {
try {
const course = await storage.createTrainingCourse(req.body);
res.json(course);
} catch (error) {
console.error("Error creating training course:", error);
res.status(500).json({ message: "Failed to create training course" });
}
});
app.patch("/api/training-courses/:id", isAuthenticated, async (req, res) => {
try {
const updated = await storage.updateTrainingCourse(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ message: "Training course not found" });
}
res.json(updated);
} catch (error) {
console.error("Error updating training course:", error);
res.status(500).json({ message: "Failed to update training course" });
}
});
app.delete("/api/training-courses/:id", isAuthenticated, async (req, res) => {
try {
await storage.deleteTrainingCourse(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting training course:", error);
res.status(500).json({ message: "Failed to delete training course" });
}
});
// ============= HOLIDAYS ROUTES =============
app.get("/api/holidays", isAuthenticated, async (req, res) => {
try {
const year = req.query.year ? parseInt(req.query.year as string) : undefined;
const holidays = await storage.getAllHolidays(year);
res.json(holidays);
} catch (error) {
console.error("Error fetching holidays:", error);
res.status(500).json({ message: "Failed to fetch holidays" });
}
});
app.post("/api/holidays", isAuthenticated, async (req, res) => {
try {
const holiday = await storage.createHoliday(req.body);
res.json(holiday);
} catch (error) {
console.error("Error creating holiday:", error);
res.status(500).json({ message: "Failed to create holiday" });
}
});
app.delete("/api/holidays/:id", isAuthenticated, async (req, res) => {
try {
await storage.deleteHoliday(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting holiday:", error);
res.status(500).json({ message: "Failed to delete holiday" });
}
});
// ============= ABSENCES ROUTES =============
app.get("/api/absences", isAuthenticated, async (req, res) => {
try {
const guardId = req.query.guardId as string | undefined;
const absences = guardId
? await storage.getAbsencesByGuard(guardId)
: await storage.getAllAbsences();
res.json(absences);
} catch (error) {
console.error("Error fetching absences:", error);
res.status(500).json({ message: "Failed to fetch absences" });
}
});
app.post("/api/absences", isAuthenticated, async (req, res) => {
try {
const absence = await storage.createAbsence(req.body);
res.json(absence);
} catch (error) {
console.error("Error creating absence:", error);
res.status(500).json({ message: "Failed to create absence" });
}
});
app.patch("/api/absences/:id", isAuthenticated, async (req, res) => {
try {
const updated = await storage.updateAbsence(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ message: "Absence not found" });
}
res.json(updated);
} catch (error) {
console.error("Error updating absence:", error);
res.status(500).json({ message: "Failed to update absence" });
}
});
app.delete("/api/absences/:id", isAuthenticated, async (req, res) => {
try {
await storage.deleteAbsence(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting absence:", error);
res.status(500).json({ message: "Failed to delete absence" });
}
});
const httpServer = createServer(app);
return httpServer;
}

View File

@ -7,6 +7,13 @@ import {
shifts,
shiftAssignments,
notifications,
guardConstraints,
sitePreferences,
trainingCourses,
holidays,
holidayAssignments,
absences,
absenceAffectedShifts,
type User,
type UpsertUser,
type Guard,
@ -21,6 +28,20 @@ import {
type InsertShiftAssignment,
type Notification,
type InsertNotification,
type GuardConstraints,
type InsertGuardConstraints,
type SitePreference,
type InsertSitePreference,
type TrainingCourse,
type InsertTrainingCourse,
type Holiday,
type InsertHoliday,
type HolidayAssignment,
type InsertHolidayAssignment,
type Absence,
type InsertAbsence,
type AbsenceAffectedShift,
type InsertAbsenceAffectedShift,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc } from "drizzle-orm";
@ -64,6 +85,44 @@ export interface IStorage {
getNotificationsByUser(userId: string): Promise<Notification[]>;
createNotification(notification: InsertNotification): Promise<Notification>;
markNotificationAsRead(id: string): Promise<void>;
// Guard Constraints operations
getGuardConstraints(guardId: string): Promise<GuardConstraints | undefined>;
upsertGuardConstraints(constraints: InsertGuardConstraints): Promise<GuardConstraints>;
// Site Preferences operations
getSitePreferences(siteId: string): Promise<SitePreference[]>;
createSitePreference(pref: InsertSitePreference): Promise<SitePreference>;
deleteSitePreference(id: string): Promise<void>;
// Training Courses operations
getTrainingCoursesByGuard(guardId: string): Promise<TrainingCourse[]>;
getAllTrainingCourses(): Promise<TrainingCourse[]>;
createTrainingCourse(course: InsertTrainingCourse): Promise<TrainingCourse>;
updateTrainingCourse(id: string, course: Partial<InsertTrainingCourse>): Promise<TrainingCourse | undefined>;
deleteTrainingCourse(id: string): Promise<void>;
// Holidays operations
getAllHolidays(year?: number): Promise<Holiday[]>;
createHoliday(holiday: InsertHoliday): Promise<Holiday>;
deleteHoliday(id: string): Promise<void>;
// Holiday Assignments operations
getHolidayAssignments(holidayId: string): Promise<HolidayAssignment[]>;
createHolidayAssignment(assignment: InsertHolidayAssignment): Promise<HolidayAssignment>;
deleteHolidayAssignment(id: string): Promise<void>;
// Absences operations
getAllAbsences(): Promise<Absence[]>;
getAbsencesByGuard(guardId: string): Promise<Absence[]>;
createAbsence(absence: InsertAbsence): Promise<Absence>;
updateAbsence(id: string, absence: Partial<InsertAbsence>): Promise<Absence | undefined>;
deleteAbsence(id: string): Promise<void>;
// Absence Affected Shifts operations
getAffectedShiftsByAbsence(absenceId: string): Promise<AbsenceAffectedShift[]>;
createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise<AbsenceAffectedShift>;
deleteAbsenceAffectedShift(id: string): Promise<void>;
}
export class DatabaseStorage implements IStorage {
@ -289,6 +348,168 @@ export class DatabaseStorage implements IStorage {
.set({ isRead: true })
.where(eq(notifications.id, id));
}
// Guard Constraints operations
async getGuardConstraints(guardId: string): Promise<GuardConstraints | undefined> {
const [constraints] = await db
.select()
.from(guardConstraints)
.where(eq(guardConstraints.guardId, guardId));
return constraints;
}
async upsertGuardConstraints(constraintsData: InsertGuardConstraints): Promise<GuardConstraints> {
const existing = await this.getGuardConstraints(constraintsData.guardId);
if (existing) {
const [updated] = await db
.update(guardConstraints)
.set({ ...constraintsData, updatedAt: new Date() })
.where(eq(guardConstraints.guardId, constraintsData.guardId))
.returning();
return updated;
} else {
const [created] = await db
.insert(guardConstraints)
.values(constraintsData)
.returning();
return created;
}
}
// Site Preferences operations
async getSitePreferences(siteId: string): Promise<SitePreference[]> {
return await db
.select()
.from(sitePreferences)
.where(eq(sitePreferences.siteId, siteId));
}
async createSitePreference(pref: InsertSitePreference): Promise<SitePreference> {
const [newPref] = await db.insert(sitePreferences).values(pref).returning();
return newPref;
}
async deleteSitePreference(id: string): Promise<void> {
await db.delete(sitePreferences).where(eq(sitePreferences.id, id));
}
// Training Courses operations
async getTrainingCoursesByGuard(guardId: string): Promise<TrainingCourse[]> {
return await db
.select()
.from(trainingCourses)
.where(eq(trainingCourses.guardId, guardId))
.orderBy(desc(trainingCourses.scheduledDate));
}
async getAllTrainingCourses(): Promise<TrainingCourse[]> {
return await db.select().from(trainingCourses).orderBy(desc(trainingCourses.scheduledDate));
}
async createTrainingCourse(course: InsertTrainingCourse): Promise<TrainingCourse> {
const [newCourse] = await db.insert(trainingCourses).values(course).returning();
return newCourse;
}
async updateTrainingCourse(id: string, courseData: Partial<InsertTrainingCourse>): Promise<TrainingCourse | undefined> {
const [updated] = await db
.update(trainingCourses)
.set(courseData)
.where(eq(trainingCourses.id, id))
.returning();
return updated;
}
async deleteTrainingCourse(id: string): Promise<void> {
await db.delete(trainingCourses).where(eq(trainingCourses.id, id));
}
// Holidays operations
async getAllHolidays(year?: number): Promise<Holiday[]> {
if (year) {
return await db
.select()
.from(holidays)
.where(eq(holidays.year, year))
.orderBy(holidays.date);
}
return await db.select().from(holidays).orderBy(holidays.date);
}
async createHoliday(holiday: InsertHoliday): Promise<Holiday> {
const [newHoliday] = await db.insert(holidays).values(holiday).returning();
return newHoliday;
}
async deleteHoliday(id: string): Promise<void> {
await db.delete(holidays).where(eq(holidays.id, id));
}
// Holiday Assignments operations
async getHolidayAssignments(holidayId: string): Promise<HolidayAssignment[]> {
return await db
.select()
.from(holidayAssignments)
.where(eq(holidayAssignments.holidayId, holidayId));
}
async createHolidayAssignment(assignment: InsertHolidayAssignment): Promise<HolidayAssignment> {
const [newAssignment] = await db.insert(holidayAssignments).values(assignment).returning();
return newAssignment;
}
async deleteHolidayAssignment(id: string): Promise<void> {
await db.delete(holidayAssignments).where(eq(holidayAssignments.id, id));
}
// Absences operations
async getAllAbsences(): Promise<Absence[]> {
return await db.select().from(absences).orderBy(desc(absences.startDate));
}
async getAbsencesByGuard(guardId: string): Promise<Absence[]> {
return await db
.select()
.from(absences)
.where(eq(absences.guardId, guardId))
.orderBy(desc(absences.startDate));
}
async createAbsence(absence: InsertAbsence): Promise<Absence> {
const [newAbsence] = await db.insert(absences).values(absence).returning();
return newAbsence;
}
async updateAbsence(id: string, absenceData: Partial<InsertAbsence>): Promise<Absence | undefined> {
const [updated] = await db
.update(absences)
.set(absenceData)
.where(eq(absences.id, id))
.returning();
return updated;
}
async deleteAbsence(id: string): Promise<void> {
await db.delete(absences).where(eq(absences.id, id));
}
// Absence Affected Shifts operations
async getAffectedShiftsByAbsence(absenceId: string): Promise<AbsenceAffectedShift[]> {
return await db
.select()
.from(absenceAffectedShifts)
.where(eq(absenceAffectedShifts.absenceId, absenceId));
}
async createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise<AbsenceAffectedShift> {
const [newAffected] = await db.insert(absenceAffectedShifts).values(affected).returning();
return newAffected;
}
async deleteAbsenceAffectedShift(id: string): Promise<void> {
await db.delete(absenceAffectedShifts).where(eq(absenceAffectedShifts.id, id));
}
}
export const storage = new DatabaseStorage();