Add system to manage different types of security services and their details

Introduce new "serviceTypes" table and CRUD operations in the backend. Update frontend to fetch and display service types dynamically. Modify vehicle assignment logic to handle null guards.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/IgrJ2ut
This commit is contained in:
marco370 2025-10-17 09:12:29 +00:00
parent 204717cd9d
commit a5355ed881
7 changed files with 228 additions and 71 deletions

View File

@ -27,6 +27,10 @@ externalPort = 3000
localPort = 42175
externalPort = 3002
[[ports]]
localPort = 43267
externalPort = 3003
[env]
PORT = "5000"

View File

@ -3,7 +3,7 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertSiteSchema, type Site } from "@shared/schema";
import { insertSiteSchema, insertServiceTypeSchema, type Site, type ServiceType } from "@shared/schema";
import { z } from "zod";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@ -35,34 +35,27 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { Building2, Shield, Eye, MapPin, Zap, Plus, Pencil } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { MapPin, Plus, Pencil } from "lucide-react";
const serviceTypeInfo = {
fixed_post: {
label: "Presidio Fisso",
description: "Guardia fissa presso una struttura",
icon: Building2,
color: "bg-blue-500/10 text-blue-500 border-blue-500/20"
},
patrol: {
label: "Pattugliamento",
description: "Ronde e controlli su area",
icon: Eye,
color: "bg-green-500/10 text-green-500 border-green-500/20"
},
night_inspection: {
label: "Ispettorato Notturno",
description: "Controlli notturni programmati",
icon: Shield,
color: "bg-purple-500/10 text-purple-500 border-purple-500/20"
},
quick_response: {
label: "Pronto Intervento",
description: "Intervento rapido su chiamata",
icon: Zap,
color: "bg-orange-500/10 text-orange-500 border-orange-500/20"
}
} as const;
// Helper to get icon component from name
const getIconComponent = (iconName: string) => {
const Icon = (LucideIcons as any)[iconName];
return Icon || LucideIcons.Building2;
};
// Helper to get color classes from color name
const getColorClasses = (color: string) => {
const colorMap: Record<string, string> = {
blue: "bg-blue-500/10 text-blue-500 border-blue-500/20",
green: "bg-green-500/10 text-green-500 border-green-500/20",
purple: "bg-purple-500/10 text-purple-500 border-purple-500/20",
orange: "bg-orange-500/10 text-orange-500 border-orange-500/20",
red: "bg-red-500/10 text-red-500 border-red-500/20",
yellow: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
};
return colorMap[color] || colorMap.blue;
};
type SiteForm = z.infer<typeof insertSiteSchema>;
@ -73,10 +66,16 @@ export default function Services() {
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<Site | null>(null);
const { data: sites = [], isLoading } = useQuery<Site[]>({
const { data: sites = [], isLoading: isLoadingSites } = useQuery<Site[]>({
queryKey: ["/api/sites"],
});
const { data: serviceTypes = [], isLoading: isLoadingServiceTypes } = useQuery<ServiceType[]>({
queryKey: ["/api/service-types"],
});
const isLoading = isLoadingSites || isLoadingServiceTypes;
const createForm = useForm<SiteForm>({
resolver: zodResolver(insertSiteSchema),
defaultValues: {
@ -183,9 +182,9 @@ export default function Services() {
};
// Calculate statistics per service type
const stats = Object.keys(serviceTypeInfo).reduce((acc, type) => {
const sitesForType = sites.filter(s => s.shiftType === type);
acc[type] = {
const stats = serviceTypes.reduce((acc, serviceType) => {
const sitesForType = sites.filter(s => s.shiftType === serviceType.code);
acc[serviceType.code] = {
total: sitesForType.length,
active: sitesForType.filter(s => s.isActive).length,
requiresArmed: sitesForType.filter(s => s.requiresArmed).length,
@ -214,56 +213,48 @@ export default function Services() {
) : (
<>
<div className="grid gap-6 md:grid-cols-2">
{Object.entries(serviceTypeInfo).map(([type, info]) => {
const Icon = info.icon;
const stat = stats[type] || { total: 0, active: 0, requiresArmed: 0, requiresDriver: 0 };
{serviceTypes.map((serviceType) => {
const Icon = getIconComponent(serviceType.icon);
const stat = stats[serviceType.code] || { total: 0, active: 0, requiresArmed: 0, requiresDriver: 0 };
return (
<Card key={type} data-testid={`card-service-${type}`}>
<Card key={serviceType.id} data-testid={`card-service-${serviceType.code}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${info.color}`}>
<div className={`p-2 rounded-lg ${getColorClasses(serviceType.color)}`}>
<Icon className="h-6 w-6" />
</div>
<div>
<CardTitle className="text-xl">{info.label}</CardTitle>
<CardDescription className="mt-1">{info.description}</CardDescription>
<CardTitle className="text-xl">{serviceType.label}</CardTitle>
<CardDescription className="mt-1">{serviceType.description}</CardDescription>
</div>
</div>
<Button
size="sm"
onClick={() => handleCreateSite(type)}
data-testid={`button-add-site-${type}`}
>
<Plus className="h-4 w-4 mr-1" />
Aggiungi Sito
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Siti Totali</p>
<p className="text-2xl font-semibold" data-testid={`text-total-${type}`}>
<p className="text-2xl font-semibold" data-testid={`text-total-${serviceType.code}`}>
{stat.total}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Attivi</p>
<p className="text-2xl font-semibold text-green-500" data-testid={`text-active-${type}`}>
<p className="text-2xl font-semibold text-green-500" data-testid={`text-active-${serviceType.code}`}>
{stat.active}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Richiedono Armati</p>
<p className="text-lg font-semibold" data-testid={`text-armed-${type}`}>
<p className="text-lg font-semibold" data-testid={`text-armed-${serviceType.code}`}>
{stat.requiresArmed}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Richiedono Patente</p>
<p className="text-lg font-semibold" data-testid={`text-driver-${type}`}>
<p className="text-lg font-semibold" data-testid={`text-driver-${serviceType.code}`}>
{stat.requiresDriver}
</p>
</div>
@ -273,15 +264,15 @@ export default function Services() {
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="font-normal">
<MapPin className="h-3 w-3 mr-1" />
{sites.filter(s => s.shiftType === type && s.location === 'roccapiemonte').length} Roccapiemonte
{sites.filter(s => s.shiftType === serviceType.code && s.location === 'roccapiemonte').length} Roccapiemonte
</Badge>
<Badge variant="outline" className="font-normal">
<MapPin className="h-3 w-3 mr-1" />
{sites.filter(s => s.shiftType === type && s.location === 'milano').length} Milano
{sites.filter(s => s.shiftType === serviceType.code && s.location === 'milano').length} Milano
</Badge>
<Badge variant="outline" className="font-normal">
<MapPin className="h-3 w-3 mr-1" />
{sites.filter(s => s.shiftType === type && s.location === 'roma').length} Roma
{sites.filter(s => s.shiftType === serviceType.code && s.location === 'roma').length} Roma
</Badge>
</div>
</div>
@ -291,10 +282,10 @@ export default function Services() {
variant="ghost"
size="sm"
className="w-full mt-4"
onClick={() => setSelectedServiceType(selectedServiceType === type ? null : type)}
data-testid={`button-view-sites-${type}`}
onClick={() => setSelectedServiceType(selectedServiceType === serviceType.code ? null : serviceType.code)}
data-testid={`button-view-sites-${serviceType.code}`}
>
{selectedServiceType === type ? "Nascondi" : "Visualizza"} Siti ({stat.total})
{selectedServiceType === serviceType.code ? "Nascondi" : "Visualizza"} Siti ({stat.total})
</Button>
)}
</CardContent>
@ -307,7 +298,7 @@ export default function Services() {
<Card>
<CardHeader>
<CardTitle>
Siti {serviceTypeInfo[selectedServiceType as keyof typeof serviceTypeInfo].label}
Siti {serviceTypes.find(st => st.code === selectedServiceType)?.label || ""}
</CardTitle>
<CardDescription>
Lista completa dei siti con questo tipo di servizio
@ -429,10 +420,9 @@ export default function Services() {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
{serviceTypes.filter(st => st.isActive).map(st => (
<SelectItem key={st.id} value={st.code}>{st.label}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
@ -616,10 +606,9 @@ export default function Services() {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem value="patrol">Pattugliamento</SelectItem>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
<SelectItem value="quick_response">Pronto Intervento</SelectItem>
{serviceTypes.filter(st => st.isActive).map(st => (
<SelectItem key={st.id} value={st.code}>{st.label}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />

View File

@ -511,14 +511,18 @@ export default function Vehicles() {
render={({ field }) => (
<FormItem>
<FormLabel>Assegnato a</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""} data-testid="select-create-guard">
<Select
onValueChange={(value) => field.onChange(value === "none" ? null : value)}
value={field.value || "none"}
data-testid="select-create-guard"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleziona guardia" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessuno</SelectItem>
<SelectItem value="none">Nessuno</SelectItem>
{guards?.map(guard => (
<SelectItem key={guard.id} value={guard.id}>
{guard.badgeNumber}
@ -738,14 +742,18 @@ export default function Vehicles() {
render={({ field }) => (
<FormItem>
<FormLabel>Assegnato a</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""} data-testid="select-edit-guard">
<Select
onValueChange={(value) => field.onChange(value === "none" ? null : value)}
value={field.value || "none"}
data-testid="select-edit-guard"
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleziona guardia" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessuno</SelectItem>
<SelectItem value="none">Nessuno</SelectItem>
{guards?.map(guard => (
<SelectItem key={guard.id} value={guard.id}>
{guard.badgeNumber}

View File

@ -418,6 +418,53 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= SERVICE TYPE ROUTES =============
app.get("/api/service-types", isAuthenticated, async (req, res) => {
try {
const serviceTypes = await storage.getAllServiceTypes();
res.json(serviceTypes);
} catch (error) {
console.error("Error fetching service types:", error);
res.status(500).json({ message: "Failed to fetch service types" });
}
});
app.post("/api/service-types", isAuthenticated, async (req, res) => {
try {
const serviceType = await storage.createServiceType(req.body);
res.json(serviceType);
} catch (error) {
console.error("Error creating service type:", error);
res.status(500).json({ message: "Failed to create service type" });
}
});
app.patch("/api/service-types/:id", isAuthenticated, async (req, res) => {
try {
const serviceType = await storage.updateServiceType(req.params.id, req.body);
if (!serviceType) {
return res.status(404).json({ message: "Service type not found" });
}
res.json(serviceType);
} catch (error) {
console.error("Error updating service type:", error);
res.status(500).json({ message: "Failed to update service type" });
}
});
app.delete("/api/service-types/:id", isAuthenticated, async (req, res) => {
try {
const serviceType = await storage.deleteServiceType(req.params.id);
if (!serviceType) {
return res.status(404).json({ message: "Service type not found" });
}
res.json(serviceType);
} catch (error) {
console.error("Error deleting service type:", error);
res.status(500).json({ message: "Failed to delete service type" });
}
});
// ============= SITE ROUTES =============
app.get("/api/sites", isAuthenticated, async (req, res) => {
try {

View File

@ -1,11 +1,58 @@
import { db } from "./db";
import { users, guards, sites, vehicles, contractParameters } from "@shared/schema";
import { users, guards, sites, vehicles, contractParameters, serviceTypes } from "@shared/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
async function seed() {
console.log("🌱 Avvio seed database multi-sede...");
// Create Service Types
console.log("📝 Creazione tipologie di servizi...");
const defaultServiceTypes = [
{
code: "fixed_post",
label: "Presidio Fisso",
description: "Guardia fissa presso una struttura",
icon: "Building2",
color: "blue",
isActive: true
},
{
code: "patrol",
label: "Pattugliamento",
description: "Ronde e controlli su area",
icon: "Eye",
color: "green",
isActive: true
},
{
code: "night_inspection",
label: "Ispettorato Notturno",
description: "Controlli notturni programmati",
icon: "Shield",
color: "purple",
isActive: true
},
{
code: "quick_response",
label: "Pronto Intervento",
description: "Intervento rapido su chiamata",
icon: "Zap",
color: "orange",
isActive: true
}
];
for (const serviceType of defaultServiceTypes) {
const existing = await db.select().from(serviceTypes).where(eq(serviceTypes.code, serviceType.code)).limit(1);
if (existing.length === 0) {
await db.insert(serviceTypes).values(serviceType);
console.log(` + Creata tipologia: ${serviceType.label}`);
} else {
console.log(` ✓ Tipologia esistente: ${serviceType.label}`);
}
}
// Create CCNL contract parameters
console.log("📋 Creazione parametri contrattuali CCNL...");
const existingParams = await db.select().from(contractParameters).limit(1);

View File

@ -16,6 +16,7 @@ import {
absences,
absenceAffectedShifts,
contractParameters,
serviceTypes,
type User,
type UpsertUser,
type Guard,
@ -48,6 +49,8 @@ import {
type InsertAbsenceAffectedShift,
type ContractParameters,
type InsertContractParameters,
type ServiceType,
type InsertServiceType,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc } from "drizzle-orm";
@ -70,6 +73,13 @@ export interface IStorage {
createCertification(cert: InsertCertification): Promise<Certification>;
updateCertificationStatus(id: string, status: "valid" | "expiring_soon" | "expired"): Promise<void>;
// Service Type operations
getAllServiceTypes(): Promise<ServiceType[]>;
getServiceType(id: string): Promise<ServiceType | undefined>;
createServiceType(serviceType: InsertServiceType): Promise<ServiceType>;
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
deleteServiceType(id: string): Promise<ServiceType | undefined>;
// Site operations
getAllSites(): Promise<Site[]>;
getSite(id: string): Promise<Site | undefined>;
@ -272,6 +282,35 @@ export class DatabaseStorage implements IStorage {
.where(eq(certifications.id, id));
}
// Service Type operations
async getAllServiceTypes(): Promise<ServiceType[]> {
return await db.select().from(serviceTypes).orderBy(desc(serviceTypes.createdAt));
}
async getServiceType(id: string): Promise<ServiceType | undefined> {
const [serviceType] = await db.select().from(serviceTypes).where(eq(serviceTypes.id, id));
return serviceType;
}
async createServiceType(serviceType: InsertServiceType): Promise<ServiceType> {
const [newServiceType] = await db.insert(serviceTypes).values(serviceType).returning();
return newServiceType;
}
async updateServiceType(id: string, serviceTypeData: Partial<InsertServiceType>): Promise<ServiceType | undefined> {
const [updated] = await db
.update(serviceTypes)
.set({ ...serviceTypeData, updatedAt: new Date() })
.where(eq(serviceTypes.id, id))
.returning();
return updated;
}
async deleteServiceType(id: string): Promise<ServiceType | undefined> {
const [deleted] = await db.delete(serviceTypes).where(eq(serviceTypes.id, id)).returning();
return deleted;
}
// Site operations
async getAllSites(): Promise<Site[]> {
return await db.select().from(sites);

View File

@ -174,6 +174,20 @@ export const vehicles = pgTable("vehicles", {
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= SERVICE TYPES =============
export const serviceTypes = pgTable("service_types", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
code: varchar("code").notNull().unique(), // fixed_post, patrol, etc.
label: varchar("label").notNull(), // Presidio Fisso, Pattugliamento, etc.
description: text("description"), // Descrizione dettagliata
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= SITES & CONTRACTS =============
export const sites = pgTable("sites", {
@ -609,6 +623,12 @@ export const insertVehicleSchema = createInsertSchema(vehicles).omit({
updatedAt: true,
});
export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({
id: true,
createdAt: true,
@ -699,6 +719,9 @@ export type Certification = typeof certifications.$inferSelect;
export type InsertVehicle = z.infer<typeof insertVehicleSchema>;
export type Vehicle = typeof vehicles.$inferSelect;
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
export type ServiceType = typeof serviceTypes.$inferSelect;
export type InsertSite = z.infer<typeof insertSiteSchema>;
export type Site = typeof sites.$inferSelect;