Add customer management features and rename planning view

Implement CRUD operations for customers, including API endpoints and database schema. Rename the "Planning Generale" view to "Planning Fissi" and update related UI elements and documentation.

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/2w7P7NW
This commit is contained in:
marco370 2025-10-23 07:58:57 +00:00
parent ba0bd4d36f
commit 8bb0386d1e
6 changed files with 145 additions and 9 deletions

View File

@ -56,7 +56,7 @@ const menuItems = [
roles: ["admin", "coordinator"],
},
{
title: "Planning Generale",
title: "Planning Fissi",
url: "/general-planning",
icon: BarChart3,
roles: ["admin", "coordinator"],

View File

@ -330,7 +330,7 @@ export default function GeneralPlanning() {
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Planning Generale
Planning Fissi
</h1>
<p className="text-muted-foreground">
Vista settimanale turni con calcolo automatico guardie mancanti

View File

@ -46,7 +46,7 @@ The database includes core tables for `users`, `guards`, `certifications`, `site
5. Assign resources and create shift
- **Location-Based Filtering**: Backend endpoints use INNER JOIN with sites table to ensure complete resource isolation between locations - guards/vehicles in one sede remain available even when assigned to shifts in other sedi
- **Site Management**: Added sede selection in site creation/editing forms with visual badges showing location in site listings
- **Planning Generale (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
- **Planning Fissi (October 18, 2025)**: New weekly planning overview feature showing all sites × 7 days in table format:
- **Contract filtering**: Shows only sites with active contracts in the week dates (`contractStartDate <= weekEnd AND contractEndDate >= weekStart`)
- Backend endpoint `/api/general-planning?weekStart=YYYY-MM-DD&location=sede` with complex joins and location filtering
- Automatic missing guards calculation: `ceil(totalShiftHours / maxHoursPerGuard) × minGuards - assignedGuards` (e.g., 24h shift, 2 guards min, 9h max = 6 total needed)

View File

@ -4,9 +4,11 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } from "@shared/schema";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema } from "@shared/schema";
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 { z } from "zod";
import { fromZodError } from "zod-validation-error";
// Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
@ -1979,6 +1981,67 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= CUSTOMER ROUTES =============
app.get("/api/customers", isAuthenticated, async (req, res) => {
try {
const customers = await storage.getAllCustomers();
res.json(customers);
} catch (error) {
console.error("Error fetching customers:", error);
res.status(500).json({ message: "Failed to fetch customers" });
}
});
app.post("/api/customers", isAuthenticated, async (req, res) => {
try {
const validatedData = insertCustomerSchema.parse(req.body);
const customer = await storage.createCustomer(validatedData);
res.json(customer);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation failed",
errors: fromZodError(error).message
});
}
console.error("Error creating customer:", error);
res.status(500).json({ message: "Failed to create customer" });
}
});
app.patch("/api/customers/:id", isAuthenticated, async (req, res) => {
try {
const validatedData = insertCustomerSchema.partial().parse(req.body);
const customer = await storage.updateCustomer(req.params.id, validatedData);
if (!customer) {
return res.status(404).json({ message: "Customer not found" });
}
res.json(customer);
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Validation failed",
errors: fromZodError(error).message
});
}
console.error("Error updating customer:", error);
res.status(500).json({ message: "Failed to update customer" });
}
});
app.delete("/api/customers/:id", isAuthenticated, async (req, res) => {
try {
const customer = await storage.deleteCustomer(req.params.id);
if (!customer) {
return res.status(404).json({ message: "Customer not found" });
}
res.json(customer);
} catch (error) {
console.error("Error deleting customer:", error);
res.status(500).json({ message: "Failed to delete customer" });
}
});
// ============= SITE ROUTES =============
app.get("/api/sites", isAuthenticated, async (req, res) => {
try {

View File

@ -4,6 +4,7 @@ import {
guards,
certifications,
vehicles,
customers,
sites,
shifts,
shiftAssignments,
@ -26,6 +27,8 @@ import {
type InsertCertification,
type Vehicle,
type InsertVehicle,
type Customer,
type InsertCustomer,
type Site,
type InsertSite,
type Shift,
@ -85,6 +88,13 @@ export interface IStorage {
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
deleteServiceType(id: string): Promise<ServiceType | undefined>;
// Customer operations
getAllCustomers(): Promise<Customer[]>;
getCustomer(id: string): Promise<Customer | undefined>;
createCustomer(customer: InsertCustomer): Promise<Customer>;
updateCustomer(id: string, customer: Partial<InsertCustomer>): Promise<Customer | undefined>;
deleteCustomer(id: string): Promise<Customer | undefined>;
// Site operations
getAllSites(): Promise<Site[]>;
getSite(id: string): Promise<Site | undefined>;
@ -342,6 +352,35 @@ export class DatabaseStorage implements IStorage {
return deleted;
}
// Customer operations
async getAllCustomers(): Promise<Customer[]> {
return await db.select().from(customers).orderBy(desc(customers.createdAt));
}
async getCustomer(id: string): Promise<Customer | undefined> {
const [customer] = await db.select().from(customers).where(eq(customers.id, id));
return customer;
}
async createCustomer(customer: InsertCustomer): Promise<Customer> {
const [newCustomer] = await db.insert(customers).values(customer).returning();
return newCustomer;
}
async updateCustomer(id: string, customerData: Partial<InsertCustomer>): Promise<Customer | undefined> {
const [updated] = await db
.update(customers)
.set({ ...customerData, updatedAt: new Date() })
.where(eq(customers.id, id))
.returning();
return updated;
}
async deleteCustomer(id: string): Promise<Customer | undefined> {
const [deleted] = await db.delete(customers).where(eq(customers.id, id)).returning();
return deleted;
}
// Site operations
async getAllSites(): Promise<Site[]> {
return await db.select().from(sites);

View File

@ -200,13 +200,35 @@ export const serviceTypes = pgTable("service_types", {
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= CUSTOMERS =============
export const customers = pgTable("customers", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").notNull(),
businessName: varchar("business_name"), // Ragione sociale
vatNumber: varchar("vat_number"), // Partita IVA
fiscalCode: varchar("fiscal_code"), // Codice fiscale
address: varchar("address"),
city: varchar("city"),
province: varchar("province"),
zipCode: varchar("zip_code"),
phone: varchar("phone"),
email: varchar("email"),
pec: varchar("pec"), // PEC (Posta Elettronica Certificata)
contactPerson: varchar("contact_person"), // Referente
notes: text("notes"),
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= SITES & CONTRACTS =============
export const sites = pgTable("sites", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").notNull(),
address: varchar("address").notNull(),
clientId: varchar("client_id").references(() => users.id),
customerId: varchar("customer_id").references(() => customers.id),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements
@ -478,7 +500,6 @@ export const usersRelations = relations(users, ({ one, many }) => ({
fields: [users.id],
references: [guards.userId],
}),
managedSites: many(sites),
notifications: many(notifications),
}));
@ -510,10 +531,14 @@ export const certificationsRelations = relations(certifications, ({ one }) => ({
}),
}));
export const customersRelations = relations(customers, ({ many }) => ({
sites: many(sites),
}));
export const sitesRelations = relations(sites, ({ one, many }) => ({
client: one(users, {
fields: [sites.clientId],
references: [users.id],
customer: one(customers, {
fields: [sites.customerId],
references: [customers.id],
}),
shifts: many(shifts),
preferences: many(sitePreferences),
@ -680,6 +705,12 @@ export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
updatedAt: true,
});
export const insertCustomerSchema = createInsertSchema(customers).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({
id: true,
createdAt: true,
@ -794,6 +825,9 @@ export type Vehicle = typeof vehicles.$inferSelect;
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
export type ServiceType = typeof serviceTypes.$inferSelect;
export type InsertCustomer = z.infer<typeof insertCustomerSchema>;
export type Customer = typeof customers.$inferSelect;
export type InsertSite = z.infer<typeof insertSiteSchema>;
export type Site = typeof sites.$inferSelect;