Add service classification to differentiate fixed and mobile planning
Introduces a new `classification` field to `serviceTypes` table and UI elements, allowing distinction between fixed and mobile services for planning purposes. Refactors date handling in route registration for improved accuracy and reliability. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/kHMnjKS
This commit is contained in:
parent
f34e8f9136
commit
c5e4c66815
@ -166,6 +166,7 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -178,6 +179,7 @@ export default function Services() {
|
|||||||
description: "",
|
description: "",
|
||||||
icon: "Building2",
|
icon: "Building2",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
|
classification: "fisso", // ✅ NUOVO: Discriminante Planning Fissi/Mobile
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -235,10 +237,7 @@ export default function Services() {
|
|||||||
description: type.description,
|
description: type.description,
|
||||||
icon: type.icon,
|
icon: type.icon,
|
||||||
color: type.color,
|
color: type.color,
|
||||||
fixedPostHours: type.fixedPostHours || null,
|
classification: type.classification, // ✅ NUOVO: includi classification
|
||||||
patrolPassages: type.patrolPassages || null,
|
|
||||||
inspectionFrequency: type.inspectionFrequency || null,
|
|
||||||
responseTimeMinutes: type.responseTimeMinutes || null,
|
|
||||||
isActive: type.isActive,
|
isActive: type.isActive,
|
||||||
});
|
});
|
||||||
setEditTypeDialogOpen(true);
|
setEditTypeDialogOpen(true);
|
||||||
@ -1071,91 +1070,28 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg">
|
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
||||||
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={createTypeForm.control}
|
control={createTypeForm.control}
|
||||||
name="fixedPostHours"
|
name="classification"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Ore Presidio Fisso</FormLabel>
|
<FormLabel>Tipo Pianificazione*</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<SelectTrigger data-testid="select-type-classification">
|
||||||
type="number"
|
<SelectValue />
|
||||||
{...field}
|
</SelectTrigger>
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 8, 12"
|
|
||||||
data-testid="input-fixed-post-hours"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
||||||
|
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="patrolPassages"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Passaggi Pattugliamento</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 3, 5"
|
|
||||||
data-testid="input-patrol-passages"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="inspectionFrequency"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Frequenza Ispezioni (min)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 60, 120"
|
|
||||||
data-testid="input-inspection-frequency"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={createTypeForm.control}
|
|
||||||
name="responseTimeMinutes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Tempo Risposta (min)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
{...field}
|
|
||||||
value={field.value || ""}
|
|
||||||
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
|
|
||||||
placeholder="es: 15, 30"
|
|
||||||
data-testid="input-response-time"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={createTypeForm.control}
|
control={createTypeForm.control}
|
||||||
@ -1309,7 +1245,30 @@ export default function Services() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 p-4 border rounded-lg">
|
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
|
||||||
|
<FormField
|
||||||
|
control={editTypeForm.control}
|
||||||
|
name="classification"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipo Pianificazione*</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger data-testid="select-edit-type-classification">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
|
||||||
|
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg" style={{display: "none"}}>
|
||||||
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
<h4 className="font-semibold text-sm">Parametri Specifici</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { storage } from "./storage";
|
|||||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } from "@shared/schema";
|
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers } from "@shared/schema";
|
||||||
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -882,23 +882,31 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
const rawWeekStart = req.query.weekStart as string || format(new Date(), "yyyy-MM-dd");
|
||||||
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
const normalizedWeekStart = rawWeekStart.split("/")[0];
|
||||||
|
|
||||||
// Valida la data
|
// ✅ CORRETTO: Valida date con regex, NON parseISO
|
||||||
const parsedWeekStart = parseISO(normalizedWeekStart);
|
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
if (!isValid(parsedWeekStart)) {
|
if (!dateRegex.test(normalizedWeekStart)) {
|
||||||
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
return res.status(400).json({ message: "Invalid date format. Use yyyy-MM-dd" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const weekStartDate = format(parsedWeekStart, "yyyy-MM-dd");
|
// ✅ CORRETTO: Costruisci Date da componenti per evitare timezone shift
|
||||||
|
const [year, month, day] = normalizedWeekStart.split("-").map(Number);
|
||||||
|
const parsedWeekStart = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
|
||||||
|
const weekStartDate = normalizedWeekStart;
|
||||||
|
|
||||||
// Ottieni location dalla query (default: roccapiemonte)
|
// Ottieni location dalla query (default: roccapiemonte)
|
||||||
const location = req.query.location as string || "roccapiemonte";
|
const location = req.query.location as string || "roccapiemonte";
|
||||||
|
|
||||||
// Calcola fine settimana (weekStart + 6 giorni)
|
// Calcola fine settimana (weekStart + 6 giorni) usando componenti
|
||||||
const weekEndDate = format(addDays(parsedWeekStart, 6), "yyyy-MM-dd");
|
const tempWeekEnd = new Date(year, month - 1, day + 6, 23, 59, 59, 999);
|
||||||
|
const weekEndYear = tempWeekEnd.getFullYear();
|
||||||
|
const weekEndMonth = tempWeekEnd.getMonth() + 1;
|
||||||
|
const weekEndDay = tempWeekEnd.getDate();
|
||||||
|
const weekEndDate = `${weekEndYear}-${String(weekEndMonth).padStart(2, '0')}-${String(weekEndDay).padStart(2, '0')}`;
|
||||||
|
|
||||||
// Timestamp per filtro contratti
|
// ✅ CORRETTO: Timestamp da componenti per query database
|
||||||
const weekStartTimestampForContract = new Date(weekStartDate);
|
const weekStartTimestampForContract = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
const weekEndTimestampForContract = new Date(weekEndDate);
|
const weekEndTimestampForContract = new Date(weekEndYear, weekEndMonth - 1, weekEndDay, 23, 59, 59, 999);
|
||||||
|
|
||||||
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
// Ottieni tutti i siti attivi della sede con contratto valido nelle date della settimana
|
||||||
const activeSites = await db
|
const activeSites = await db
|
||||||
@ -917,11 +925,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Ottieni tutti i turni della settimana per la sede
|
// Ottieni tutti i turni della settimana per la sede
|
||||||
const weekStartTimestamp = new Date(weekStartDate);
|
// ✅ CORRETTO: Usa timestamp già creati correttamente sopra
|
||||||
weekStartTimestamp.setHours(0, 0, 0, 0);
|
const weekStartTimestamp = weekStartTimestampForContract;
|
||||||
|
const weekEndTimestamp = weekEndTimestampForContract;
|
||||||
const weekEndTimestamp = new Date(weekEndDate);
|
|
||||||
weekEndTimestamp.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const weekShifts = await db
|
const weekShifts = await db
|
||||||
.select({
|
.select({
|
||||||
@ -971,14 +977,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const weekData = [];
|
const weekData = [];
|
||||||
|
|
||||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||||
const currentDay = addDays(parsedWeekStart, dayOffset);
|
// ✅ CORRETTO: Calcola date usando componenti per evitare timezone shift
|
||||||
const dayStr = format(currentDay, "yyyy-MM-dd");
|
const currentDayTimestamp = new Date(year, month - 1, day + dayOffset, 0, 0, 0, 0);
|
||||||
|
const currentYear = currentDayTimestamp.getFullYear();
|
||||||
|
const currentMonth = currentDayTimestamp.getMonth() + 1;
|
||||||
|
const currentDay_num = currentDayTimestamp.getDate();
|
||||||
|
const dayStr = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(currentDay_num).padStart(2, '0')}`;
|
||||||
|
|
||||||
const dayStartTimestamp = new Date(dayStr);
|
const dayStartTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 0, 0, 0, 0);
|
||||||
dayStartTimestamp.setHours(0, 0, 0, 0);
|
const dayEndTimestamp = new Date(currentYear, currentMonth - 1, currentDay_num, 23, 59, 59, 999);
|
||||||
|
|
||||||
const dayEndTimestamp = new Date(dayStr);
|
|
||||||
dayEndTimestamp.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
const sitesData = activeSites.map(({ sites: site, service_types: serviceType }: any) => {
|
||||||
// Trova turni del giorno per questo sito
|
// Trova turni del giorno per questo sito
|
||||||
|
|||||||
@ -91,6 +91,11 @@ export const locationEnum = pgEnum("location", [
|
|||||||
"roma", // Sede Roma
|
"roma", // Sede Roma
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const serviceClassificationEnum = pgEnum("service_classification", [
|
||||||
|
"fisso", // Presidio fisso - Planning Fissi
|
||||||
|
"mobile", // Pattuglie/ronde/interventi - Planning Mobile
|
||||||
|
]);
|
||||||
|
|
||||||
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
||||||
|
|
||||||
// Session storage table - mandatory for Replit Auth
|
// Session storage table - mandatory for Replit Auth
|
||||||
@ -189,6 +194,9 @@ export const serviceTypes = pgTable("service_types", {
|
|||||||
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
icon: varchar("icon").notNull().default("Building2"), // Nome icona Lucide
|
||||||
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
color: varchar("color").notNull().default("blue"), // blue, green, purple, orange
|
||||||
|
|
||||||
|
// ✅ NUOVO: Classificazione servizio - determina quale planning usare
|
||||||
|
classification: serviceClassificationEnum("classification").notNull().default("fisso"),
|
||||||
|
|
||||||
// Parametri specifici per tipo servizio
|
// Parametri specifici per tipo servizio
|
||||||
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
|
||||||
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user