Compare commits

..

199 Commits

Author SHA1 Message Date
Marco Lanzara
591687b000 🚀 Release v1.1.1
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.1.1_20251115_101122.sql.gz
- Data: 2025-11-15 10:11:44
2025-11-15 10:11:44 +00:00
marco370
ee5d1aaa24 Show all guards in planning, marking those already busy
Update the general planning view to display all guards, regardless of their current availability or contractual compatibility. Guards who are already assigned to a shift are now visually indicated in red but remain selectable for alternative shift assignments. This change involves modifying the guard filtering logic and adding visual cues for busy guards in the selection interface.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/HjUaTbs
2025-10-29 09:56:33 +00:00
marco370
fc6f5a39f8 Fix issue preventing patrol route saving due to conflicting shifts
Modify shift conflict checking logic to properly handle date comparisons by including 'less than' operator.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/p38S9Gi
2025-10-29 09:31:44 +00:00
marco370
758a697447 Add detailed validation and conflict resolution for patrol shifts
Implement new API endpoint `/api/patrol-routes/check-overlaps` for shift conflict detection, including fixed posts, other mobile routes, and weekly hour compliance. Introduce a new "force-save" dialog for manual conflict overrides and enhance the patrol route duplication feature to support multi-day operations with overlap validation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/p38S9Gi
2025-10-29 09:24:38 +00:00
marco370
40cde1634b Improve link testing by normalizing URL slugs in sidebar
Update `data-testid` attributes in `AppSidebar` component to use a normalized slug representation of menu item titles, ensuring more robust end-to-end testing by replacing spaces with hyphens. Also, add a new section describing the "Guardie Settimanale" feature in the `replit.md` documentation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EoraZGH
2025-10-29 08:52:43 +00:00
marco370
7b05c8cbce Add a weekly guard schedule overview page
Add a new page to display a summarized weekly schedule for guards, divided by location, with detailed shift information accessible on click. Includes error handling for data fetching.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
2025-10-29 08:30:30 +00:00
marco370
d868c6ee31 Add weekly guard schedule view with shift details
Implements the "Guardie Settimanale" page, displaying a summarized weekly schedule per guard and location. Includes a dialog for detailed shift information (fixed or mobile) upon clicking a cell, integrating new components and logic for interactive schedule viewing.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
2025-10-29 08:26:54 +00:00
marco370
a10b50e7e9 Add weekly guard schedule overview page
Introduce a new page for viewing weekly guard schedules, displaying guard assignments, absences, and site information per day.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
2025-10-29 08:25:05 +00:00
marco370
c8825a9b4c Add a page to view the weekly guard schedule by location
Implement a new API endpoint `/api/weekly-guards-schedule` to retrieve and display guard assignments, including fixed shifts, mobile shifts, and absences, for a specified location and week.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
2025-10-29 08:23:21 +00:00
marco370
7961971ad0 Organize the main navigation menu with new sub-menus and updated labels
Refactor the `app-sidebar.tsx` component to restructure the navigation menu, introducing collapsible sub-menus for planning and master data, and renaming several menu items.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
2025-10-29 08:08:37 +00:00
Marco Lanzara
3e3b9851a8 🚀 Release v1.1.0
- Tipo: minor
- Database backup: database-backups/vigilanzaturni_v1.1.0_20251025_090838.sql.gz
- Data: 2025-10-25 09:08:56
2025-10-25 09:08:56 +00:00
Marco Lanzara
3fc0c55fd2 🚀 Release v1.0.58
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.58_20251025_090445.sql.gz
- Data: 2025-10-25 09:05:02
2025-10-25 09:05:02 +00:00
marco370
2eb53bb1b2 Improve security by updating user authentication protocols
Update authentication logic and dependencies to enhance system security and compliance.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 09:01:45 +00:00
marco370
a9f3453755 Improve mobile patrol planning with smart assignments and route optimization
Enhance Planning Mobile functionality with smart site assignment indicators, drag-and-drop reordering using @dnd-kit, and OSRM API integration for route optimization.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 09:01:11 +00:00
marco370
dd84ddb35b Add route optimization and display results in mobile planning
Integrate a new API endpoint for route optimization, update the UI to include Sparkles icon, and implement state management for optimization results including total distance and estimated time.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:59:53 +00:00
marco370
5c22ec14f1 Add route optimization to improve patrol efficiency
Implement a new API endpoint that uses OSRM and the Nearest Neighbor algorithm to optimize delivery routes, calculating distances, durations, and providing an ordered list of stops.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:53:50 +00:00
marco370
efa056dd98 Allow users to reorder planned stops using drag-and-drop
Integrate @dnd-kit for implementing drag-and-drop functionality to reorder stops within the mobile planning view.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/N0pfy8w
2025-10-25 08:52:36 +00:00
marco370
b132082ffc Improve patrol planning by showing assigned guards and scrolling to sequences
Add functionality to display assigned guards for patrol routes and scroll to patrol sequences section.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/Z8fg4as
2025-10-25 08:50:05 +00:00
Marco Lanzara
e5ce415aeb 🚀 Release v1.0.57
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.57_20251025_080211.sql.gz
- Data: 2025-10-25 08:02:29
2025-10-25 08:02:29 +00:00
marco370
34bdb99599 Update shift status when duplicating patrol routes
Correctly set the shift status to "planned" instead of "scheduled" when duplicating a patrol route to resolve an enum type error.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/Z8fg4as
2025-10-25 08:00:37 +00:00
marco370
3cc1739015 Fix error that occurs when viewing user profile information
Resolve null reference exception in UserProfileComponent.cs by adding null checks.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/Z8fg4as
2025-10-25 07:57:02 +00:00
marco370
20f24ba25e Fix error when copying patrol routes to a different date
Update date field name from `scheduledDate` to `shiftDate` in patrol route copying logic to resolve a data inconsistency issue.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/Z8fg4as
2025-10-25 07:56:17 +00:00
Marco Lanzara
bd55070abc 🚀 Release v1.0.56
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.56_20251025_074951.sql.gz
- Data: 2025-10-25 07:50:09
2025-10-25 07:50:09 +00:00
Marco Lanzara
7a6fd3245b 🚀 Release v1.0.55
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.55_20251025_072838.sql.gz
- Data: 2025-10-25 07:28:56
2025-10-25 07:28:56 +00:00
marco370
bafc34065e Fix invalid date error when duplicating shifts on mobile
Corrected date validation logic in the shift duplication dialog on the mobile planning interface to resolve "invalid date" errors and ensure successful duplication.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/tgJ9Kmd
2025-10-25 06:39:36 +00:00
marco370
b4c6400360 Fix date validation for duplicating shifts in the mobile planning view
Update date parsing and validation logic in PlanningMobile.tsx to correctly handle `shiftDate` instead of `scheduledDate` for duplicating shifts, resolving "invalid date" errors and duplicate confirmation issues.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/tgJ9Kmd
2025-10-25 06:38:55 +00:00
Marco Lanzara
753e01d612 🚀 Release v1.0.54
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.54_20251024_172526.sql.gz
- Data: 2025-10-24 17:25:44
2025-10-24 17:25:44 +00:00
marco370
3b3056f6b8 Improve planning duplication functionality with error handling and validation
Add validation for selected date and error handling for opening the duplicate dialog in the mobile planning view, also improve date display for scheduled routes.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/JYtd6x2
2025-10-24 17:08:57 +00:00
Marco Lanzara
2b62d8ff4e 🚀 Release v1.0.53
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.53_20251024_163455.sql.gz
- Data: 2025-10-24 16:35:13
2025-10-24 16:35:13 +00:00
marco370
1639244169 Add system to manage guard shifts and client portal functionality
Implement a new API endpoint for guard shift management and a client portal.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:47:54 +00:00
marco370
2cd6c32ad9 Add ability to duplicate shifts and patrol routes to streamline planning
Introduces POST /api/shift-assignments/copy-week and POST /api/patrol-routes/duplicate endpoints for duplicating weekly fixed shifts and mobile patrol routes, respectively, with corresponding frontend dialogs.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:47:25 +00:00
marco370
0a72b413fa Add functionality to duplicate weekly schedules and patrol routes
Introduces a dialog to copy weekly schedules to the next week and duplicates patrol routes with specified guards and dates, updating the client-side UI and API interactions.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:46:11 +00:00
marco370
1bad21cf9e Add functionality to duplicate and modify patrol routes
Adds a new POST endpoint `/api/patrol-routes/duplicate` to duplicate existing patrol routes to a new date, optionally assigning a different guard. If the target date is the same as the source date, it updates the guard for the existing route.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:40:27 +00:00
marco370
6366382753 Add functionality to copy weekly shifts to the next week
Introduce a new feature allowing users to copy weekly shift assignments to the subsequent week via a dedicated button, including a confirmation dialog and error handling for the copy operation. The UI also includes an update to the navigation bar for better responsiveness.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:38:51 +00:00
marco370
0b64fd2f08 Add functionality to copy weekly shift assignments to the following week
Introduce a new POST API endpoint `/api/shift-assignments/copy-week` to duplicate existing shift assignments and their associated shifts for a specified location and week, automatically adjusting dates by adding 7 days.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:36:54 +00:00
marco370
36bfad3815 Add a new system for managing security guard shift schedules
No changes to review.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EDxr1e6
2025-10-24 15:35:33 +00:00
Marco Lanzara
d8f22f81da 🚀 Release v1.0.52
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.52_20251024_145327.sql.gz
- Data: 2025-10-24 14:53:47
2025-10-24 14:53:47 +00:00
marco370
b1ba5b91c0 Improve system description and database schema details for security shift management
Update replit.md to include a more detailed overview of the VigilanzaTurni system and refine the database schema description.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/HO4k2VG
2025-10-24 14:27:28 +00:00
marco370
5830d08c55 Improve user interface for managing guard schedules and shifts
Add new UI components and update existing ones to enhance the user experience for scheduling and managing guard shifts.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/HO4k2VG
2025-10-24 14:26:28 +00:00
marco370
468d6477eb Adjust time display to show correct local times for users
Update time formatting logic to consistently display scheduled times in the 'Europe/Rome' timezone, resolving inconsistencies caused by UTC storage.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/HO4k2VG
2025-10-24 14:22:57 +00:00
marco370
74bd542309 Fix incorrect shift assignments due to timezone conversion errors
Address timezone discrepancies by implementing a function to calculate the correct offset for Europe/Rome, ensuring accurate conversion of shift start and end times from local time to UTC for assignment processing.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/7VmWFMu
2025-10-24 13:50:00 +00:00
Marco Lanzara
580fbfcaab 🚀 Release v1.0.51
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.51_20251024_132912.sql.gz
- Data: 2025-10-24 13:29:30
2025-10-24 13:29:30 +00:00
marco370
565cd08f10 Exclude mobile sites from fixed scheduling views
Ensure sites categorized as "mobile" are not displayed in the fixed planning section.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/aneFGWm
2025-10-24 10:48:49 +00:00
marco370
52baa7f6c3 Exclude mobile sites from fixed planning schedules
Filter out sites classified as "mobile" from the fixed planning module in server/routes.ts by modifying the site filtering logic.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/aneFGWm
2025-10-24 10:47:28 +00:00
Marco Lanzara
c8fa396c8f 🚀 Release v1.0.50
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.50_20251024_102610.sql.gz
- Data: 2025-10-24 10:26:29
2025-10-24 10:26:29 +00:00
marco370
5c8ebf7218 Add pages to view security guard shifts and client data
Implement new frontend pages for displaying guard shift schedules and client information.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/rjLU1aT
2025-10-23 17:20:22 +00:00
marco370
fbc4f96a46 Improve service planning display and guard information retrieval
Updates the header text for the service planning page from "Visione Servizi" to "Planning di Servizio". Modifies the backend to retrieve and display guard names by concatenating first and last names, instead of using a single `fullName` field. Adjusts database query ordering for guards and patrol route stops, and refactors the `patrolRouteStops` join condition.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/rjLU1aT
2025-10-23 17:20:00 +00:00
Marco Lanzara
bb50965eba 🚀 Release v1.0.49
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.49_20251023_170434.sql.gz
- Data: 2025-10-23 17:04:52
2025-10-23 17:04:52 +00:00
marco370
cf0c905d0f Add functionality to manage service requests with attachments
Add new API endpoint and controller for managing service requests, including file uploads via multipart/form-data.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/rjLU1aT
2025-10-23 16:57:33 +00:00
marco370
00ac8c8415 Add mobile patrol routes and distinguish between fixed and mobile guard duties
Introduce new data structures and API endpoints for mobile patrol routes, differentiating them from fixed guard shifts in the service planning interface.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/rjLU1aT
2025-10-23 16:57:03 +00:00
marco370
ab85e8eb03 Restored to '4a2b5fab66e760175f7609180824ca0ac4f08d5a'
Replit-Restored-To: 4a2b5fab66
2025-10-23 16:38:19 +00:00
marco370
1c183a18ec Saved your changes before rolling back
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: a0a13201-ca6f-49fe-8e12-193e1d995c28
Replit-Commit-Checkpoint-Type: full_checkpoint
2025-10-23 16:38:16 +00:00
marco370
e0504f0a13 Add planning consultation views and reorganize sidebar navigation
Introduce new planning consultation pages for fixed and mobile agents, refactor sidebar navigation into logical groups, and enhance shift assignment logic by preventing double-booking of 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/kDVJJUd
2025-10-23 16:34:28 +00:00
marco370
4a2b5fab66 Update patrol route saving to handle existing routes correctly
Modify the `savePatrolRouteMutation` in `planning-mobile.tsx` to use PUT for updating existing patrol routes and POST for creating new ones, addressing a 400 error when modifying patrol sequences.

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/kDVJJUd
2025-10-23 16:09:30 +00:00
Marco Lanzara
1cdae8c4b3 🚀 Release v1.0.48
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.48_20251023_160305.sql.gz
- Data: 2025-10-23 16:03:23
2025-10-23 16:03:23 +00:00
marco370
0d01252d86 Create database backup for system version 1.0.48
Create database backup file 'vigilanzaturni_v1.0.48_20251023_160305.sql'.

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/kDVJJUd
2025-10-23 16:03:12 +00:00
marco370
6430fbe707 Update shift assignment date filtering to improve accuracy
Modify the query in `server/routes.ts` to correctly filter shift assignments by comparing the date part of `shifts.startTime` with `routeData.shiftDate`.

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/kDVJJUd
2025-10-23 16:02:01 +00:00
marco370
f50d5deb60 Fix error when saving patrol route stops with missing notes
Correctly handle missing `notes` field for patrol route stops during creation, resolving a 500 error.

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/WbUtQAg
2025-10-23 15:59:13 +00:00
Marco Lanzara
5281861053 🚀 Release v1.0.47
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.47_20251023_155134.sql.gz
- Data: 2025-10-23 15:51:51
2025-10-23 15:51:51 +00:00
marco370
ce6478e77e Improve map display by showing site locations correctly
Fixes an issue where map markers were not displaying correctly on the mobile planning page by implementing a default blue icon for sites not on a patrol route.

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/WbUtQAg
2025-10-23 15:50:14 +00:00
Marco Lanzara
6d2e92c76e 🚀 Release v1.0.46
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.46_20251023_152240.sql.gz
- Data: 2025-10-23 15:23:00
2025-10-23 15:23:00 +00:00
marco370
c7c0830780 Improve local login handling with Passport.js authentication
Update the `/api/local-login` route to use Passport.js middleware for robust local authentication, including error handling for authentication failures and successful login.

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/WbUtQAg
2025-10-23 15:21:02 +00:00
marco370
cc92c26836 Improve security by adding authentication and authorization
Implement JWT authentication and role-based authorization middleware to secure API endpoints and control user access.

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/WbUtQAg
2025-10-23 15:10:25 +00:00
marco370
50b74cdaba Add detailed planning views for guards and site coordinators
Implement new routes and UI components for guards to view fixed and mobile shifts, and for coordinators to view site-specific guard assignments.

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/WbUtQAg
2025-10-23 15:07:13 +00:00
marco370
d6b9811c2b Enforce exclusive assignments for guards across shift types
Adds checks to prevent guards from being assigned to both fixed shifts and mobile patrol routes on the same date by validating against existing assignments in the database for the specified shift and guard.

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/ZaT6tFl
2025-10-23 15:01:18 +00:00
marco370
897a674eee Add patrol route planning and display for mobile users
Implement a new API endpoint and client-side logic for creating, fetching, and displaying patrol routes, including stop details, on the mobile planning interface.

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/ZaT6tFl
2025-10-23 14:58:08 +00:00
Marco Lanzara
fc63a3a081 🚀 Release v1.0.45
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.45_20251023_145254.sql.gz
- Data: 2025-10-23 14:53:12
2025-10-23 14:53:12 +00:00
marco370
ef7b7f8723 Add functionality to assign guards to patrol and fixed services
Implement logic to prevent assigning a guard to both patrol and fixed services simultaneously.

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/ZaT6tFl
2025-10-23 14:51:39 +00:00
marco370
62b5cb997f Add patrol routes and related entities for guard scheduling
Introduces new database tables and relations for `patrolRoutes`, `patrolRouteStops`, and updates `shiftAssignments` with new fields like `isArmedOnDuty` and `assignedVehicleId`. Also updates relations for `guards` and `sites`.

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/ZaT6tFl
2025-10-23 14:51:16 +00:00
Marco Lanzara
84cb770877 🚀 Release v1.0.44
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.44_20251023_143746.sql.gz
- Data: 2025-10-23 14:38:03
2025-10-23 14:38:03 +00:00
marco370
f05f05ca57 Improve map interactions for planning and guard assignment
Fix geocoding bug, implement map zooming to site, allow guard assignment to sites, and enable patrol route sequencing via map marker clicks.

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/ZaT6tFl
2025-10-23 14:37:33 +00:00
marco370
32e5647dd3 Add interactive map features and guard assignment functionality
Implement map centering on site selection, guard assignment to sites, and patrol route creation with Toast notifications and Leaflet map controls.

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/ZaT6tFl
2025-10-23 14:34:31 +00:00
Marco Lanzara
92ac90315a 🚀 Release v1.0.43
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.43_20251023_140453.sql.gz
- Data: 2025-10-23 14:05:10
2025-10-23 14:05:10 +00:00
marco370
1c70d1cdc9 Add database backup file for system version 1.0.43
Adds database backup file 'vigilanzaturni_v1.0.43_20251023_140453.sql' to the database-backups directory.

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/HdlP8Fl
2025-10-23 14:04:57 +00:00
marco370
e9f1c1e136 Update geocoding API calls to correctly parse JSON responses
Correctly parse the JSON response from the `/api/geocode` endpoint in the Sites page component by calling `.json()` on the response object.

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/HdlP8Fl
2025-10-23 14:03:38 +00:00
marco370
17b1969255 Ensure site address is always a string for editing
Update `sites.tsx` to default `site.address` to an empty string if null or undefined when editing a site.

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/HdlP8Fl
2025-10-23 13:56:14 +00:00
Marco Lanzara
5017532439 🚀 Release v1.0.42
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.42_20251023_135048.sql.gz
- Data: 2025-10-23 13:51:05
2025-10-23 13:51:05 +00:00
marco370
6a63f54bc1 Fix issue with undefined coordinates when editing site information
Update the site editing form in `client/src/pages/sites.tsx` to initialize latitude and longitude fields with empty strings instead of undefined, resolving the "address undefined" error.

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/HdlP8Fl
2025-10-23 13:49:23 +00:00
Marco Lanzara
3b2ac3d0cd 🚀 Release v1.0.41
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.41_20251023_134119.sql.gz
- Data: 2025-10-23 13:41:37
2025-10-23 13:41:37 +00:00
marco370
c66642e0a1 Add functionality for tracking guard movements using geofencing
Implement geofencing feature to monitor guard location and ensure adherence to assigned patrol routes.

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/TFybNy5
2025-10-23 11:08:32 +00:00
marco370
db67aa9f61 Add automatic geocoding for site addresses to improve GPS accuracy
Integrate Nominatim API via a new backend endpoint and frontend button to automatically convert site addresses into GPS coordinates, enhancing accuracy for the mobile planning map.

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/TFybNy5
2025-10-23 11:08:10 +00:00
marco370
0c702f4dbf Add GPS coordinate lookup and display for site locations
Integrate OpenStreetMap Nominatim API for geocoding addresses to latitude and longitude, enabling GPS coordinate storage and display for sites. Update User-Agent for Nominatim requests.

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/TFybNy5
2025-10-23 11:06:56 +00:00
marco370
db860125fc Add address lookup using OpenStreetMap data
Integrate Nominatim API endpoint to geocode addresses with rate limiting and user-agent configuration.

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/TFybNy5
2025-10-23 10:59:32 +00:00
Marco Lanzara
66dc97855e 🚀 Release v1.0.40
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.40_20251023_104924.sql.gz
- Data: 2025-10-23 10:49:40
2025-10-23 10:49:40 +00:00
marco370
90f5061d95 Add feature to display upcoming planned shifts for guards
Update Guard and Shift models and related views to fetch and display upcoming shifts for 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/TFybNy5
2025-10-23 10:48:47 +00:00
marco370
33b69f5ecc Add interactive map to mobile planning and fix backend issues
Integrate Leaflet map into Planning Mobile, displaying sites with GPS coordinates and automatic re-centering. Fix backend issues related to site and guard data, service type joins, and order by syntax.

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/TFybNy5
2025-10-23 10:48:23 +00:00
marco370
7431145ee3 Integrate map functionality for planning mobile view
Add Leaflet map integration to the planning mobile view, displaying sites with coordinates and filtering guards with driver licenses.

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/TFybNy5
2025-10-23 10:46:54 +00:00
marco370
0cfa154e61 Add mapping capabilities for patrol planning and guard assignments
Integrate Leaflet and React Leaflet libraries to enable map display and functionality for patrol planning and guard assignments.

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/AXEqh9q
2025-10-23 10:40:52 +00:00
marco370
bf5cfdcd50 Show only guards with driver's licenses and update site filtering
Modify the query to include guards with driver's licenses and use a left join for service types.

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/AXEqh9q
2025-10-23 10:38:40 +00:00
marco370
392079d2a1 Update site management to use dynamic service types
Integrate `serviceTypeId` FK in the `sites` table to link to `service_types` table, replacing deprecated `shiftType` field. Modify site creation and editing forms to dynamically load and select service types, and update card display to show service type labels from the database.

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/AXEqh9q
2025-10-23 10:28:08 +00:00
marco370
a48577c9b8 Update site management to use service types instead of shift types
Introduces the ability to select service types when creating or editing sites, replacing the previous shift type field. It fetches available active service types from the API and displays them in a dropdown.

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/AXEqh9q
2025-10-23 10:25:43 +00:00
Marco Lanzara
9ee37d8ea1 🚀 Release v1.0.39
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.39_20251023_101800.sql.gz
- Data: 2025-10-23 10:18:17
2025-10-23 10:18:17 +00:00
marco370
a829fbf3a9 Created a checkpoint
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/AXEqh9q
2025-10-23 10:17:48 +00:00
Marco Lanzara
162deaa3b7 🚀 Release v1.0.38
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.38_20251023_095257.sql.gz
- Data: 2025-10-23 09:53:14
2025-10-23 09:53:14 +00:00
marco370
b762edb113 Fix errors in site management and mobile planning features
Resolve 404 errors in mobile planning and empty pages in site management due to incorrect API routing and data fetching logic.

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/IcLh7if
2025-10-23 09:45:16 +00:00
marco370
e143aa3f60 Update site management for better shift type selection
Modify sites.tsx to set a default location and ensure proper value binding for the shift type select component.

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/IcLh7if
2025-10-23 09:44:45 +00:00
marco370
37cbbfa768 Improve site management interface to correctly assign customers
Update the `sites.tsx` page to correctly handle `customerId` when creating or editing sites, ensuring the customer selection works as expected.

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/IcLh7if
2025-10-23 09:35:46 +00:00
Marco Lanzara
0f58cba38c 🚀 Release v1.0.37
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.37_20251023_092311.sql.gz
- Data: 2025-10-23 09:23:29
2025-10-23 09:23:29 +00:00
marco370
e8b84ec7d4 Fix issues with the mobile planning, site references, and data loading
Fixes bugs in mobile routing, site-to-client association, and data loading for fixed planning.

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/1nTItRR
2025-10-23 09:21:23 +00:00
marco370
cfd4ad1d8d Fix issues with mobile planning, site references, and data loading errors
Fix routing for mobile planning, add client references to sites, and resolve data loading errors in fixed planning.

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/1nTItRR
2025-10-23 09:21:00 +00:00
marco370
0b8392808f Fix error when loading data in fixed shift planning
Update dayOfWeek calculation to use correct timestamp in weekData generation.

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/1nTItRR
2025-10-23 09:17:40 +00:00
marco370
f18e85e79a Add customer selection to site creation and editing forms
Update the `sites.tsx` page to fetch and display customer data, enabling users to associate sites with specific customers during creation and editing. This involves adding a new `Select` component for customer selection in both the site creation form and the edit site form, and updating the `InsertSite` schema to include `customerId`.

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/1nTItRR
2025-10-23 09:16:27 +00:00
Marco Lanzara
51691bda11 🚀 Release v1.0.36
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.36_20251023_090614.sql.gz
- Data: 2025-10-23 09:06:31
2025-10-23 09:06:31 +00:00
marco370
eef9b6027d Update project overview and clarify date handling rules
Update the project overview section in README.md and introduce mandatory rules for handling date/timezone conversions to prevent bugs, with specific examples and references to affected files and previous commits.

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/1nTItRR
2025-10-23 09:05:47 +00:00
marco370
bd4a55e001 Add mobile planning interface and backend endpoints
Introduce a new "Planning Mobile" section to the application, including a frontend page (client/src/pages/planning-mobile.tsx) for managing mobile services (patrols, inspections, interventions) and backend API routes (server/routes.ts) to fetch relevant sites and guard availability based on location and date. This also includes updates to the app sidebar (client/src/components/app-sidebar.tsx) and router (client/src/App.tsx) to integrate the new functionality.

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/1nTItRR
2025-10-23 08:52:16 +00:00
marco370
c5e4c66815 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
2025-10-23 08:46:28 +00:00
Marco Lanzara
f34e8f9136 🚀 Release v1.0.35
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.35_20251023_082911.sql.gz
- Data: 2025-10-23 08:29:30
2025-10-23 08:29:30 +00:00
marco370
7b102a1a1c Add customer management and billing reports for organized client tracking
Introduced a new `customers` table with CRUD operations for customer management and a new `/api/reports/customer-billing` endpoint for customer-centric billing reports, replacing site-based reporting.

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/kHMnjKS
2025-10-23 08:27:49 +00:00
marco370
18aa847dab Add customer billing reports and CSV export functionality
Implement a new API endpoint to fetch customer billing data, including site and service type breakdowns, and add functionality to export this data to CSV.

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
2025-10-23 08:26:33 +00:00
marco370
af98190e6d Add a dedicated section for managing customer information
Introduces a new page and routing for customer management, including UI components for viewing, creating, editing, and deleting customers, along with API integration for CRUD operations.

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
2025-10-23 08:21:20 +00:00
marco370
983adcfbe1 Fix date shifting issues for shift assignments
Update date handling logic in `server/routes.ts` to prevent timezone-related shifts when assigning shifts, by parsing dates using components instead of ISO strings. Documentation in `replit.md` has also been updated with new rules to avoid this recurring problem.

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/2w7P7NW
2025-10-23 08:15:59 +00:00
Marco Lanzara
1598eb208b 🚀 Release v1.0.34
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.34_20251023_080247.sql.gz
- Data: 2025-10-23 08:03:06
2025-10-23 08:03:06 +00:00
marco370
8bb0386d1e 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
2025-10-23 07:58:57 +00:00
marco370
ba0bd4d36f Add confirmation dialog for guard assignments exceeding limits
Update general planning to include AlertDialog for CCNL_VIOLATION errors, allowing forced guard assignments and displaying service details.

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/2w7P7NW
2025-10-23 07:46:11 +00:00
marco370
9c28befcb1 Allow overriding daily hour limits for guard assignments
Update the general planning API endpoint to include a 'force' option, enabling the assignment of guards even if they exceed the daily hour limit. This change adds a confirmation prompt for such cases and refactors error handling to provide more specific messages.

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/2w7P7NW
2025-10-23 07:41:34 +00:00
marco370
fb99b5f738 Update planning dialog to show fresh data and improve guard assignment display
Refactors the general planning dialog to use a computed property `currentCellData` for fetching the latest site data, ensuring real-time updates. Updates the display of assigned guards and site statistics (shifts, hours, missing guards) to reflect this fresh data.

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/2w7P7NW
2025-10-23 07:40:31 +00:00
marco370
153f272c15 Fix issues with shift assignment and guard allocation
Correct date assignment for shifts, prevent multiplication of assigned guards, and ensure real-time updates in the guard selection dialog.

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/TWQ52cO
2025-10-22 09:12:45 +00:00
marco370
3b7c55b55b Improve planning accuracy and real-time updates for guard shifts
Fixes an issue where dates were incorrectly shifted due to timezone handling and ensures the guard assignment dialog updates in real-time after modifications. Additionally, refactors the calculation of needed guards to accurately reflect site service hours and minimum guard requirements.

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/TWQ52cO
2025-10-22 09:12:20 +00:00
Marco Lanzara
d8a6ec9c49 🚀 Release v1.0.33
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.33_20251022_085205.sql.gz
- Data: 2025-10-22 08:52:21
2025-10-22 08:52:21 +00:00
marco370
0fe9363aa9 Improve vehicle assignment logic in general planning
Update the general planning page to correctly handle the "none" vehicle selection, ensuring that the vehicleId is only included in the assignment payload when a valid vehicle is chosen.

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/NB1Ej1f
2025-10-22 08:45:18 +00:00
marco370
ce00b4d946 Add image file for visual assets
No code changes, only an image file was added.

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/NB1Ej1f
2025-10-22 08:39:47 +00:00
Marco Lanzara
c40c6a5b47 🚀 Release v1.0.32
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.32_20251022_083408.sql.gz
- Data: 2025-10-22 08:34:24
2025-10-22 08:34:24 +00:00
marco370
4dd441dd84 Fix error when viewing site details in the general planning view
The empty dialog when clicking on a site in the general planning view has been fixed.

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/NB1Ej1f
2025-10-22 08:30:21 +00:00
marco370
03531f0d5c Show assigned guards and vehicle information in the planning view
Fixes an issue where the guard assignment form was not displaying correctly in the general planning view by ensuring all necessary data is fetched and rendered.

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/NB1Ej1f
2025-10-22 08:29:59 +00:00
Marco Lanzara
03049f4090 🚀 Release v1.0.31
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.31_20251022_081911.sql.gz
- Data: 2025-10-22 08:19:28
2025-10-22 08:19:28 +00:00
marco370
2fd5764415 Add user roles and permissions for enhanced security
Implement RBAC model with roles (Admin, Supervisor, Guard) and permissions for CRUD operations on shifts, users, and locations.

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/KiuJzNf
2025-10-22 08:15:13 +00:00
marco370
b05bd3a0b9 Add monthly guard and site reports for specific locations
Implement new API endpoints and UI components to generate and display monthly reports for guard hours (including overtime and meal vouchers) and billable site hours, with filtering by month and location.

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/KiuJzNf
2025-10-22 08:13:59 +00:00
marco370
efcaca356a Add a new section for viewing and managing service planning details
Implement the "Service Planning" page with backend API routes and frontend components for displaying guard and site schedules.

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/KiuJzNf
2025-10-22 08:08:00 +00:00
marco370
a945abdb5d Add vehicle assignment to guard planning and improve dialog size
Integrate vehicle assignment into the general planning module by adding a `vehicleId` field to the `assign-guard` mutation and fetching available vehicles. Increase the dialog size for better usability and update related cache invalidations.

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/KiuJzNf
2025-10-22 07:59:10 +00:00
marco370
d7c6136fcb Add ability to assign vehicles to guards for specific shifts
Adds a new API endpoint to retrieve available vehicles by location and modifies the general planning route to include vehicle assignments for guards.

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/KiuJzNf
2025-10-22 07:50:19 +00:00
marco370
52f3aee8e4 Add development tools to reset and seed the application data
Introduces API endpoints `/api/dev/reset-data` (DELETE) and `/api/dev/seed-data` (POST) for clearing and populating the database with sample sites and guards, intended for development and testing purposes.

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/Jxn533V
2025-10-22 07:48:24 +00:00
Marco Lanzara
24a1c81d6e 🚀 Release v1.0.30
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.30_20251022_071255.sql.gz
- Data: 2025-10-22 07:13:11
2025-10-22 07:13:11 +00:00
marco370
82442a5dd9 Add daily guard hour limit checks and improve shift assignment display
Implement validation for daily guard working hours, including overtime detection. Refactor shift assignment logic to prevent guards from appearing in shifts after their ordinary hours are completed, with an exception for displaying guards with overtime.

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/Jxn533V
2025-10-21 17:43:31 +00:00
marco370
b1b320ab69 Update planning to enforce daily guard hour limits and improve assignment logic
Add daily hour limit checks during guard assignment and ensure guards with completed ordinary hours are not displayed unless they have overtime. Refactor guard availability query to refetch immediately on assignment success and update staleTime to 0.

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/Jxn533V
2025-10-21 17:38:29 +00:00
marco370
1c34d3f79e Enforce daily working hour limits for security guards
Implement daily hour limit checks for guard assignments based on CCNL regulations, preventing assignments that exceed 9 hours per day.

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/Jxn533V
2025-10-21 17:36:14 +00:00
Marco Lanzara
19158357fb 🚀 Release v1.0.29
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.29_20251021_171921.sql.gz
- Data: 2025-10-21 17:19:38
2025-10-21 17:19:38 +00:00
marco370
c2c35b62b5 Fix time display to prevent incorrect hour shifts
Correct the time formatting logic in the general planning component to explicitly use UTC, resolving an issue where shifts of +2 hours were incorrectly applied due to local timezone conversions.

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/qoWuIE4
2025-10-21 17:06:41 +00:00
Marco Lanzara
10b543ebab 🚀 Release v1.0.28
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.28_20251021_165619.sql.gz
- Data: 2025-10-21 16:56:37
2025-10-21 16:56:37 +00:00
marco370
a84f21bf24 Improve guard assignment logic in shift scheduling
Refactor shift assignment dialog to remove duplicate guard listings, enable scrolling for full visibility, and implement real-time filtering of available guards based on contractual hours, with an option to include guards eligible for overtime.

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/qoWuIE4
2025-10-21 16:53:06 +00:00
marco370
da547137b7 Improve guard selection by filtering based on availability and overtime
Refactors the guard selection UI to dynamically filter available guards, showing regular hours first and providing an option to display those requiring overtime.

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/qoWuIE4
2025-10-21 16:51:34 +00:00
marco370
b782f16797 Improve guard assignment logic and display in the planning tool
Implement real-time filtering of available guards based on standard and overtime hours, including night hour constraints and daily rest periods. Enhance UI to toggle overtime guard visibility and improve dialog scrollability.

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/G1ZUdV2
2025-10-21 16:45:18 +00:00
Marco Lanzara
3a7f44f49f 🚀 Release v1.0.27
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.27_20251021_162727.sql.gz
- Data: 2025-10-21 16:27:43
2025-10-21 16:27:43 +00:00
marco370
4633f6ef5f Improve company settings and access controls for improved security
Modify CompanySettingsController and related services to enhance access control mechanisms and update company settings.

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/G1ZUdV2
2025-10-21 16:08:27 +00:00
marco370
cd3622a97e Allow removing guards from scheduled shifts
Remove the unassign guard mutation and its associated toast notifications from the GeneralPlanning page, as this functionality is no longer required.

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/G1ZUdV2
2025-10-21 16:07:32 +00:00
marco370
eec694d9d1 Improve shift planning by adding time display and deletion functionality
Adds Italian time formatting, assignment deletion, and displays planned start/end times for guards in the general planning view.

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/G1ZUdV2
2025-10-21 15:52:57 +00:00
Marco Lanzara
dd7adeaa24 🚀 Release v1.0.26
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.26_20251021_154042.sql.gz
- Data: 2025-10-21 15:40:58
2025-10-21 15:40:58 +00:00
marco370
100f20e422 Add ability to assign guards for multiple consecutive days
Adds support for multi-day guard assignments by modifying the assign-guard API endpoint and client-side logic to accept and process a `consecutiveDays` parameter.

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/U8icLKT
2025-10-21 14:40:16 +00:00
Marco Lanzara
62f8189e7d 🚀 Release v1.0.25
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.25_20251021_141055.sql.gz
- Data: 2025-10-21 14:11:11
2025-10-21 14:11:11 +00:00
marco370
a23b46b9fd Improve the security and privacy of user data
Enhance data protection by implementing encryption for sensitive user information.

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/NBwOcnR
2025-10-21 07:45:29 +00:00
marco370
052cc6896a Improve error reporting and conflict visualization for shift assignments
Refactor shift assignment logic to use database transactions and improve error message parsing for assignment failures.

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/NBwOcnR
2025-10-21 07:44:30 +00:00
marco370
3d80f75f43 Update planning to assign guards with specific times and durations
Introduce new functionality to assign guards to specific time slots within shifts, modifying the UI and backend to handle startTime and durationHours.

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/ZZOTK7r
2025-10-21 06:42:54 +00:00
marco370
c95bf04abf Improve guard scheduling with time slot conflict detection and assignment
Add API endpoints for retrieving guard availability with date range filtering and for creating shift assignments with planned start and end times, including validation and conflict detection.

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/ZZOTK7r
2025-10-21 06:38:25 +00:00
marco370
c72125c68f Improve guard assignment and availability checks for shift planning
Update storage interface and implementation to handle shift assignment deletion,
modify getGuardsAvailability to accept specific planned start and end times,
and introduce conflict detection logic for guard availability. Add new DTOs
(guardConflictSchema) and update guardAvailabilitySchema to include availability
status, conflicts, and unavailability reasons, enhancing shift planning accuracy.

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/ZZOTK7r
2025-10-21 06:36:42 +00:00
marco370
1caf5c4199 Improve guard shift assignment with planned start and end times
Update the schema for shift assignments to include planned start and end times, and extend the insert schema to validate these times, ensuring guards are assigned to specific time slots.

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/McZSLgC
2025-10-21 06:33:40 +00:00
Marco Lanzara
18e219e118 🚀 Release v1.0.24
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.24_20251018_102519.sql.gz
- Data: 2025-10-18 10:25:34
2025-10-18 10:25:35 +00:00
marco370
b4ca7d594e Add a way for users to safely log in to their accounts
Add a new API endpoint for user authentication.

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/McZSLgC
2025-10-18 10:11:42 +00:00
marco370
ce1ba6ef33 Update guard details to show more relevant information
Refactors the Guards page to display guard's email, phone number, and location. Also updates initial fallback for Avatar component and changes formatting for CardDescription.

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/McZSLgC
2025-10-18 10:11:23 +00:00
marco370
1dcb20400f Add missing guard details to the guard management form
Update client/src/pages/guards.tsx to include firstName, lastName, and email fields in the guard form, and update shared/schema.ts to reflect the new fields in the insertGuardSchema.

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/2o9hx6y
2025-10-18 09:29:57 +00:00
Marco Lanzara
9bc4ed03d8 🚀 Release v1.0.23
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.23_20251018_092039.sql.gz
- Data: 2025-10-18 09:20:55
2025-10-18 09:20:55 +00:00
marco370
8068a808de Add ability to create multi-day shifts from planning interface
Update client to allow creating multi-day shifts directly from the General Planning dialog, and fix the `apiRequest` parameter order in the mutation.

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/zGfvPmX
2025-10-18 09:20:23 +00:00
marco370
eb3e6c4aac Add functionality to create shifts directly from the planning view
Introduces API endpoints and client-side logic for fetching guard availability and creating multi-day shifts from the general planning interface.

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/DrGaAl6
2025-10-18 08:49:02 +00:00
Marco Lanzara
6f9e24a76e 🚀 Release v1.0.22
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.22_20251018_082656.sql.gz
- Data: 2025-10-18 08:27:14
2025-10-18 08:27:14 +00:00
marco370
74524206a5 Improve user data management by handling updates and inserts more robustly
Modify upsertUser logic in DatabaseStorage to first check for existing user by id or email, then perform an update or insert operation accordingly.

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/DrGaAl6
2025-10-18 08:23:11 +00:00
Marco Lanzara
6b6db9474e 🚀 Release v1.0.21
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.21_20251018_081811.sql.gz
- Data: 2025-10-18 08:18:27
2025-10-18 08:18:27 +00:00
marco370
b1e5a13882 Update planning view to show guard coverage and calculation status
Add new UI elements to display guard coverage status, including total needed, assigned, and missing guards, and update the display logic to accurately reflect these calculations.

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/uZXH8P1
2025-10-18 07:46:25 +00:00
Marco Lanzara
2616fb775a 🚀 Release v1.0.20
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.20_20251018_074134.sql.gz
- Data: 2025-10-18 07:41:50
2025-10-18 07:41:50 +00:00
marco370
cf5fabbdab Update shift planning to display relevant sites and available guards
Update the planning module to filter sites based on contract dates and display guards not yet assigned to shifts.

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/uZXH8P1
2025-10-18 07:41:26 +00:00
marco370
4d6fb9dff8 Improve planning overview to show sites with active contracts
Update the general planning overview to filter sites by active contract dates and display a weekly summary of total guards needed, assigned, and missing.

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/uZXH8P1
2025-10-18 07:40:54 +00:00
marco370
63ce62ee24 Add a summary of guard availability to the planning view
Update the `GeneralPlanningResponse` interface to include a `summary` object containing `totalGuardsNeeded`, `totalGuardsAssigned`, and `totalGuardsMissing`. Render this summary in the UI.

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/uZXH8P1
2025-10-18 07:39:02 +00:00
marco370
c07441cd72 Filter sites by contract dates and calculate weekly guard summary
Update `registerRoutes` to filter active sites by contract validity dates within the specified week and calculate total guards needed, assigned, and missing for the week.

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/uZXH8P1
2025-10-18 07:38:01 +00:00
marco370
edbd1f1aae Enhance planning features with multi-location support and weekly overview
Add location-based resource isolation, a general weekly planning overview with missing guard calculation, and improve operational planning page integration.

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/uZXH8P1
2025-10-18 07:27:10 +00:00
marco370
cafaa76608 Update planning page to use URL parameters for location and date
Incorporate `useLocation` hook from `wouter` to read `date` and `location` from URL search parameters, allowing pre-selection of these values and updating component state accordingly via `useEffect`.

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/uZXH8P1
2025-10-18 07:24:05 +00:00
marco370
fad541525b Add ability to view and edit shift details for each location
Integrates a dialog modal for detailed shift information, enabling navigation to operational planning and allowing users to view and potentially edit shift assignments.

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/uZXH8P1
2025-10-18 07:21:39 +00:00
marco370
10a113c4a7 Add a comprehensive general planning view with missing guard calculations
Adds a new client-side route and component for general planning, integrates it into the sidebar navigation, and updates server-side routes to fetch and process shift, guard, and vehicle assignment data for weekly planning views.

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/uZXH8P1
2025-10-18 07:20:15 +00:00
marco370
14758fab56 Add general weekly shift planning with missing guard calculations
Introduce a new API endpoint '/api/general-planning' to fetch weekly shift schedules, including active sites, assigned guards, and calculates missing guard requirements based on shift durations and site needs.

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/5GnGQQ0
2025-10-18 07:17:19 +00:00
Marco Lanzara
f0c0321d1a 🚀 Release v1.0.19
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.19_20251017_172429.sql.gz
- Data: 2025-10-17 17:24:45
2025-10-17 17:24:45 +00:00
marco370
6f6fb4f90c Add basic user authentication and authorization to the system
Implement JWT authentication and role-based authorization middleware, defining user roles and permissions.

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/5GnGQQ0
2025-10-17 17:21:13 +00:00
marco370
eb2ccab920 Add multi-location support for operational planning
Introduce location filtering to operational planning, site management, and resource availability queries. This includes backend route modifications to handle location parameters and frontend updates for location selection and display.

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/5GnGQQ0
2025-10-17 17:20:41 +00:00
marco370
121206a492 Improve operational planning by fixing date handling and selection logic
Fixes issues with date validation and event propagation in operational planning, and updates resource query keys for better data fetching.

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/09WwRvv
2025-10-17 16:36:09 +00:00
marco370
1edc335ca6 Improve date filtering for daily shift assignments
Update SQL queries to use date range comparisons for shift start times, replacing direct date string matching.

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/BjRzszS
2025-10-17 16:00:28 +00:00
Marco Lanzara
0ab1a804eb 🚀 Release v1.0.18
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.18_20251017_155640.sql.gz
- Data: 2025-10-17 15:56:56
2025-10-17 15:56:56 +00:00
marco370
3b2c347aec Improve site creation and editing display and optimize date filtering
Adjust dialog content height for better site form visibility and optimize shift retrieval by using SQL date comparison instead of date range filtering.

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/BjRzszS
2025-10-17 15:54:45 +00:00
Marco Lanzara
54d0048d5d 🚀 Release v1.0.17
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.17_20251017_154110.sql.gz
- Data: 2025-10-17 15:41:26
2025-10-17 15:41:26 +00:00
marco370
19ffe5931b Filter sites by contract validity to accurately reflect coverage
Refine site filtering in route registration to include only sites with valid contracts for the selected date, updating coverage calculations and total site counts accordingly.

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/FlO7tHX
2025-10-17 15:35:37 +00:00
Marco Lanzara
9d33dbfa22 🚀 Release v1.0.16
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.16_20251017_151927.sql.gz
- Data: 2025-10-17 15:19:44
2025-10-17 15:19:44 +00:00
marco370
470cd9262b Add contract management and service type linkage to site data
Update the database schema to include contract details for sites and link sites to service types, while making shift type optional.

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/FlO7tHX
2025-10-17 14:57:46 +00:00
marco370
e4d3ab514c Add contract start and end dates for sites and validate shifts
Implement contract start/end date validation for sites and enforce shift creation within contract boundaries on the server. Add contract status display to the client.

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/FlO7tHX
2025-10-17 14:35:51 +00:00
marco370
c8b273d9a6 Add contract details and service times to site management
Introduce new fields for contract reference, start/end dates, and service times in the `sites` schema and UI for managing site contracts.

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/UBH5igx
2025-10-17 14:29:48 +00:00
Marco Lanzara
76af862a6b 🚀 Release v1.0.15
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.15_20251017_140916.sql.gz
- Data: 2025-10-17 14:09:32
2025-10-17 14:09:32 +00:00
marco370
8ed55e05cc Enhance site management with specialized service parameters and schedules
Update database schema to include `serviceStartTime`, `serviceEndTime` for sites and specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, `responseTimeMinutes` for service types.

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/UBH5igx
2025-10-17 14:04:22 +00:00
marco370
72b7dfe74d Add ability to plan operational shifts with resource assignments
Implement a new feature for operational planning that allows users to select sites, assign guards and vehicles, and create shifts with specific start and end times. This includes updates to the UI for displaying uncovered sites and resources, as well as backend logic for creating shift assignments.

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/sshIJbn
2025-10-17 13:57:54 +00:00
marco370
283b24bcb6 Add operational planning view for uncovered sites
Introduces a new API endpoint `/api/operational-planning/uncovered-sites` that queries for sites not fully covered by assigned guards on a given date, returning sites with partial or no coverage.

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/sshIJbn
2025-10-17 13:52:54 +00:00
marco370
144a281657 Add service time parameters for fixed post and patrol services
Introduces new fields for `serviceStartTime`, `serviceEndTime`, `fixedPostHours`, `patrolPassages`, and `inspectionFrequency` in the Services section of the client-side application.

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/sshIJbn
2025-10-17 13:49:30 +00:00
marco370
a6c3ba293b Add service-specific parameters and site scheduling details
Update schema definitions in `shared/schema.ts` to include service type parameters (e.g., `fixedPostHours`, `patrolPassages`) and site-specific service times (`serviceStartTime`, `serviceEndTime`) to support more detailed scheduling requirements.

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/sshIJbn
2025-10-17 13:44:37 +00:00
Marco Lanzara
fecfe44542 🚀 Release v1.0.14
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.14_20251017_132528.sql.gz
- Data: 2025-10-17 13:25:44
2025-10-17 13:25:45 +00:00
marco370
b20fa0ae7d Introduce time tracking and attendance monitoring for guards
Add new endpoint for time tracking, update guard and shift models, and implement attendance logic.

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/sshIJbn
2025-10-17 10:52:08 +00:00
marco370
4092e8c8e9 Update operational planning to display correct daily availability
Refactors date handling for operational planning API to use ISO strings and UTC, improving accuracy and consistency.

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/sshIJbn
2025-10-17 10:51:36 +00:00
marco370
e3ab9e2b83 Add operational planning view for vehicle and guard availability
Implement a new route and page for operational planning, allowing users to select a date and view available vehicles and guards, with sorting and assignment indicators.

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/EEOXc3D
2025-10-17 10:25:42 +00:00
marco370
181de6a028 Add operational planning view for vehicle and guard availability
Implement GET /api/operational-planning/availability endpoint to fetch and sort vehicles and guards based on their availability and CCNL rules for a given date.

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/EEOXc3D
2025-10-17 10:24:01 +00:00
75 changed files with 12011 additions and 419 deletions

20
.replit
View File

@ -19,14 +19,34 @@ externalPort = 80
localPort = 33035 localPort = 33035
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 40417
externalPort = 8000
[[ports]]
localPort = 41295
externalPort = 5173
[[ports]] [[ports]]
localPort = 41343 localPort = 41343
externalPort = 3000 externalPort = 3000
[[ports]]
localPort = 41803
externalPort = 4200
[[ports]] [[ports]]
localPort = 42175 localPort = 42175
externalPort = 3002 externalPort = 3002
[[ports]]
localPort = 42187
externalPort = 6800
[[ports]]
localPort = 43169
externalPort = 5000
[[ports]] [[ports]]
localPort = 43267 localPort = 43267
externalPort = 3003 externalPort = 3003

BIN
after-reload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
after_assign_click.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
after_login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
after_sidebar_toggle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
cell_clicked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@ -18,6 +18,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -22,6 +22,15 @@ import Vehicles from "@/pages/vehicles";
import Parameters from "@/pages/parameters"; import Parameters from "@/pages/parameters";
import Services from "@/pages/services"; import Services from "@/pages/services";
import Planning from "@/pages/planning"; import Planning from "@/pages/planning";
import OperationalPlanning from "@/pages/operational-planning";
import GeneralPlanning from "@/pages/general-planning";
import ServicePlanning from "@/pages/service-planning";
import Customers from "@/pages/customers";
import PlanningMobile from "@/pages/planning-mobile";
import MyShiftsFixed from "@/pages/my-shifts-fixed";
import MyShiftsMobile from "@/pages/my-shifts-mobile";
import SitePlanningView from "@/pages/site-planning-view";
import WeeklyGuards from "@/pages/weekly-guards";
function Router() { function Router() {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@ -36,11 +45,20 @@ function Router() {
<Route path="/" component={Dashboard} /> <Route path="/" component={Dashboard} />
<Route path="/guards" component={Guards} /> <Route path="/guards" component={Guards} />
<Route path="/sites" component={Sites} /> <Route path="/sites" component={Sites} />
<Route path="/customers" component={Customers} />
<Route path="/services" component={Services} /> <Route path="/services" component={Services} />
<Route path="/vehicles" component={Vehicles} /> <Route path="/vehicles" component={Vehicles} />
<Route path="/shifts" component={Shifts} /> <Route path="/shifts" component={Shifts} />
<Route path="/planning" component={Planning} /> <Route path="/planning" component={Planning} />
<Route path="/operational-planning" component={OperationalPlanning} />
<Route path="/general-planning" component={GeneralPlanning} />
<Route path="/service-planning" component={ServicePlanning} />
<Route path="/planning-mobile" component={PlanningMobile} />
<Route path="/advanced-planning" component={AdvancedPlanning} /> <Route path="/advanced-planning" component={AdvancedPlanning} />
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
<Route path="/site-planning-view" component={SitePlanningView} />
<Route path="/weekly-guards" component={WeeklyGuards} />
<Route path="/reports" component={Reports} /> <Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} /> <Route path="/notifications" component={Notifications} />
<Route path="/users" component={Users} /> <Route path="/users" component={Users} />

View File

@ -11,6 +11,12 @@ import {
ClipboardList, ClipboardList,
Car, Car,
Briefcase, Briefcase,
Navigation,
ChevronDown,
FileText,
FolderKanban,
Building2,
Wrench,
} from "lucide-react"; } from "lucide-react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { import {
@ -22,15 +28,31 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarHeader, SidebarHeader,
SidebarFooter, SidebarFooter,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
const menuItems = [ interface MenuItem {
title: string;
url?: string;
icon: any;
roles: string[];
items?: MenuItem[];
}
const menuItems: MenuItem[] = [
{ {
title: "Dashboard", title: "Dashboard",
url: "/", url: "/",
@ -38,23 +60,47 @@ const menuItems = [
roles: ["admin", "coordinator", "guard", "client"], roles: ["admin", "coordinator", "guard", "client"],
}, },
{ {
title: "Turni", title: "Planning",
url: "/shifts", icon: FolderKanban,
roles: ["admin", "coordinator"],
items: [
{
title: "Fissi",
url: "/general-planning",
icon: Calendar, icon: Calendar,
roles: ["admin", "coordinator", "guard"], roles: ["admin", "coordinator"],
}, },
{ {
title: "Pianificazione", title: "Mobili",
url: "/planning", url: "/planning-mobile",
icon: Navigation,
roles: ["admin", "coordinator"],
},
{
title: "Vista",
url: "/service-planning",
icon: ClipboardList, icon: ClipboardList,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{ {
title: "Gestione Pianificazioni", title: "Guardie Settimanale",
url: "/weekly-guards",
icon: Users,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Scadenziario",
url: "/advanced-planning", url: "/advanced-planning",
icon: ClipboardList, icon: Calendar,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{
title: "Anagrafiche",
icon: Building2,
roles: ["admin", "coordinator"],
items: [
{ {
title: "Guardie", title: "Guardie",
url: "/guards", url: "/guards",
@ -67,6 +113,25 @@ const menuItems = [
icon: MapPin, icon: MapPin,
roles: ["admin", "coordinator", "client"], roles: ["admin", "coordinator", "client"],
}, },
{
title: "Clienti",
url: "/customers",
icon: Briefcase,
roles: ["admin", "coordinator"],
},
{
title: "Automezzi",
url: "/vehicles",
icon: Car,
roles: ["admin", "coordinator"],
},
],
},
{
title: "Tipologia",
icon: Wrench,
roles: ["admin", "coordinator"],
items: [
{ {
title: "Servizi", title: "Servizi",
url: "/services", url: "/services",
@ -74,23 +139,31 @@ const menuItems = [
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{ {
title: "Parco Automezzi", title: "Contratti",
url: "/vehicles", url: "/parameters",
icon: Car, icon: Settings,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
],
},
{ {
title: "Report", title: "Report",
url: "/reports",
icon: BarChart3, icon: BarChart3,
roles: ["admin", "coordinator", "client"], roles: ["admin", "coordinator", "client"],
items: [
{
title: "Report Amministrativo",
url: "/reports",
icon: FileText,
roles: ["admin", "coordinator", "client"],
},
],
}, },
{ {
title: "Notifiche", title: "Utilità",
url: "/notifications", icon: Settings,
icon: Bell,
roles: ["admin", "coordinator", "guard"], roles: ["admin", "coordinator", "guard"],
}, items: [
{ {
title: "Utenti", title: "Utenti",
url: "/users", url: "/users",
@ -98,10 +171,12 @@ const menuItems = [
roles: ["admin"], roles: ["admin"],
}, },
{ {
title: "Parametri", title: "Notifiche",
url: "/parameters", url: "/notifications",
icon: Settings, icon: Bell,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator", "guard"],
},
],
}, },
]; ];
@ -109,9 +184,78 @@ export function AppSidebar() {
const { user } = useAuth(); const { user } = useAuth();
const [location] = useLocation(); const [location] = useLocation();
const filteredItems = menuItems.filter( const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
(item) => user && item.roles.includes(user.role) if (!user) return [];
return items.filter((item) => {
const hasRole = item.roles.includes(user.role);
if (!hasRole) return false;
if (item.items) {
item.items = filterMenuItems(item.items);
return item.items.length > 0;
}
return true;
});
};
const filteredItems = filterMenuItems(menuItems);
const renderMenuItem = (item: MenuItem) => {
// Menu item con sottomenu
if (item.items && item.items.length > 0) {
const isAnySubItemActive = item.items.some((subItem) => location === subItem.url);
return (
<Collapsible key={item.title} defaultOpen={isAnySubItemActive} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton data-testid={`menu-${item.title.toLowerCase()}`}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={location === subItem.url}
data-testid={`link-${subItem.title.toLowerCase().replace(/\s+/g, '-')}`}
>
<Link href={subItem.url!}>
<subItem.icon className="h-4 w-4" />
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
); );
}
// Menu item semplice
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location === item.url}
data-testid={`link-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
>
<Link href={item.url!}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
};
return ( return (
<Sidebar> <Sidebar>
@ -130,20 +274,7 @@ export function AppSidebar() {
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel> <SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{filteredItems.map((item) => ( {filteredItems.map(renderMenuItem)}
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location === item.url}
data-testid={`link-${item.title.toLowerCase()}`}
>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>

View File

@ -0,0 +1,616 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Customer, InsertCustomer, insertCustomerSchema } from "@shared/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Building2, Pencil, Trash2, Phone, Mail } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function Customers() {
const { toast } = useToast();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deletingCustomerId, setDeletingCustomerId] = useState<string | null>(null);
const { data: customers, isLoading } = useQuery<Customer[]>({
queryKey: ["/api/customers"],
});
const form = useForm<InsertCustomer>({
resolver: zodResolver(insertCustomerSchema),
defaultValues: {
name: "",
businessName: "",
vatNumber: "",
fiscalCode: "",
address: "",
city: "",
province: "",
zipCode: "",
phone: "",
email: "",
pec: "",
contactPerson: "",
notes: "",
isActive: true,
},
});
const createMutation = useMutation({
mutationFn: async (data: InsertCustomer) => {
return await apiRequest("POST", "/api/customers", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente creato",
description: "Il cliente è stato aggiunto con successo",
});
setIsCreateDialogOpen(false);
form.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: InsertCustomer }) => {
return await apiRequest("PATCH", `/api/customers/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente aggiornato",
description: "I dati del cliente sono stati aggiornati",
});
setEditingCustomer(null);
form.reset();
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
return await apiRequest("DELETE", `/api/customers/${id}`, undefined);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/customers"] });
toast({
title: "Cliente eliminato",
description: "Il cliente è stato eliminato con successo",
});
setDeletingCustomerId(null);
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
setDeletingCustomerId(null);
},
});
const onSubmit = (data: InsertCustomer) => {
if (editingCustomer) {
updateMutation.mutate({ id: editingCustomer.id, data });
} else {
createMutation.mutate(data);
}
};
const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer);
form.reset({
name: customer.name || "",
businessName: customer.businessName || "",
vatNumber: customer.vatNumber || "",
fiscalCode: customer.fiscalCode || "",
address: customer.address || "",
city: customer.city || "",
province: customer.province || "",
zipCode: customer.zipCode || "",
phone: customer.phone || "",
email: customer.email || "",
pec: customer.pec || "",
contactPerson: customer.contactPerson || "",
notes: customer.notes || "",
isActive: customer.isActive ?? true,
});
setIsCreateDialogOpen(true);
};
const handleDialogOpenChange = (open: boolean) => {
setIsCreateDialogOpen(open);
if (!open) {
// Reset only on close
setEditingCustomer(null);
form.reset();
}
};
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight" data-testid="text-page-title">
Anagrafica Clienti
</h1>
<p className="text-muted-foreground">
Gestione anagrafica clienti e contratti
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button data-testid="button-create-customer">
<Plus className="mr-2 h-4 w-4" />
Nuovo Cliente
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingCustomer ? "Modifica Cliente" : "Nuovo Cliente"}
</DialogTitle>
<DialogDescription>
Inserisci i dati anagrafici del cliente
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Nome Cliente *</FormLabel>
<FormControl>
<Input
placeholder="es. Banca Centrale Roma"
data-testid="input-name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="businessName"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Ragione Sociale</FormLabel>
<FormControl>
<Input
placeholder="es. Banca Centrale S.p.A."
data-testid="input-business-name"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="vatNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Partita IVA</FormLabel>
<FormControl>
<Input
placeholder="12345678901"
data-testid="input-vat-number"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fiscalCode"
render={({ field }) => (
<FormItem>
<FormLabel>Codice Fiscale</FormLabel>
<FormControl>
<Input
placeholder="CF cliente"
data-testid="input-fiscal-code"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Indirizzo</FormLabel>
<FormControl>
<Input
placeholder="Via, numero civico"
data-testid="input-address"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>Città</FormLabel>
<FormControl>
<Input
placeholder="Roma"
data-testid="input-city"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="province"
render={({ field }) => (
<FormItem>
<FormLabel>Provincia</FormLabel>
<FormControl>
<Input
placeholder="RM"
maxLength={2}
data-testid="input-province"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="zipCode"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>CAP</FormLabel>
<FormControl>
<Input
placeholder="00100"
data-testid="input-zip-code"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefono</FormLabel>
<FormControl>
<Input
placeholder="+39 06 1234567"
data-testid="input-phone"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="info@cliente.it"
data-testid="input-email"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pec"
render={({ field }) => (
<FormItem>
<FormLabel>PEC</FormLabel>
<FormControl>
<Input
type="email"
placeholder="pec@cliente.it"
data-testid="input-pec"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contactPerson"
render={({ field }) => (
<FormItem>
<FormLabel>Referente</FormLabel>
<FormControl>
<Input
placeholder="Nome e cognome referente"
data-testid="input-contact-person"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Note</FormLabel>
<FormControl>
<Textarea
placeholder="Note aggiuntive sul cliente"
data-testid="input-notes"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0 col-span-2">
<FormControl>
<Switch
checked={field.value ?? true}
onCheckedChange={field.onChange}
data-testid="switch-is-active"
/>
</FormControl>
<FormLabel className="!mt-0">Cliente Attivo</FormLabel>
</FormItem>
)}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => handleDialogOpenChange(false)}
data-testid="button-cancel"
>
Annulla
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
data-testid="button-submit"
>
{editingCustomer ? "Aggiorna" : "Crea"}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{/* Customers Table */}
<Card>
<CardHeader>
<CardTitle>Lista Clienti</CardTitle>
<CardDescription>
{customers?.length || 0} clienti registrati
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Ragione Sociale</TableHead>
<TableHead>Città</TableHead>
<TableHead>Referente</TableHead>
<TableHead>Contatti</TableHead>
<TableHead>Stato</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers?.map((customer) => (
<TableRow key={customer.id} data-testid={`row-customer-${customer.id}`}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
{customer.name}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{customer.businessName || "-"}
</TableCell>
<TableCell>
{customer.city ? `${customer.city} (${customer.province})` : "-"}
</TableCell>
<TableCell>{customer.contactPerson || "-"}</TableCell>
<TableCell>
<div className="flex flex-col gap-1 text-sm">
{customer.phone && (
<div className="flex items-center gap-1">
<Phone className="h-3 w-3" />
{customer.phone}
</div>
)}
{customer.email && (
<div className="flex items-center gap-1">
<Mail className="h-3 w-3" />
{customer.email}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={customer.isActive ? "default" : "secondary"}>
{customer.isActive ? "Attivo" : "Inattivo"}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(customer)}
data-testid={`button-edit-${customer.id}`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeletingCustomerId(customer.id)}
data-testid={`button-delete-${customer.id}`}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{(!customers || customers.length === 0) && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Nessun cliente registrato
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deletingCustomerId} onOpenChange={() => setDeletingCustomerId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Conferma eliminazione</AlertDialogTitle>
<AlertDialogDescription>
Sei sicuro di voler eliminare questo cliente? L'operazione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-cancel-delete">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletingCustomerId && deleteMutation.mutate(deletingCustomerId)}
data-testid="button-confirm-delete"
className="bg-destructive hover:bg-destructive/90"
>
Elimina
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertGuardSchema, insertCertificationSchema } from "@shared/schema"; import { insertGuardSchema, insertCertificationSchema } from "@shared/schema";
@ -31,8 +32,12 @@ export default function Guards() {
const form = useForm<InsertGuard>({ const form = useForm<InsertGuard>({
resolver: zodResolver(insertGuardSchema), resolver: zodResolver(insertGuardSchema),
defaultValues: { defaultValues: {
firstName: "",
lastName: "",
email: "",
badgeNumber: "", badgeNumber: "",
phoneNumber: "", phoneNumber: "",
location: "roccapiemonte",
isArmed: false, isArmed: false,
hasFireSafety: false, hasFireSafety: false,
hasFirstAid: false, hasFirstAid: false,
@ -44,8 +49,12 @@ export default function Guards() {
const editForm = useForm<InsertGuard>({ const editForm = useForm<InsertGuard>({
resolver: zodResolver(insertGuardSchema), resolver: zodResolver(insertGuardSchema),
defaultValues: { defaultValues: {
firstName: "",
lastName: "",
email: "",
badgeNumber: "", badgeNumber: "",
phoneNumber: "", phoneNumber: "",
location: "roccapiemonte",
isArmed: false, isArmed: false,
hasFireSafety: false, hasFireSafety: false,
hasFirstAid: false, hasFirstAid: false,
@ -111,8 +120,12 @@ export default function Guards() {
const openEditDialog = (guard: GuardWithCertifications) => { const openEditDialog = (guard: GuardWithCertifications) => {
setEditingGuard(guard); setEditingGuard(guard);
editForm.reset({ editForm.reset({
firstName: guard.firstName || "",
lastName: guard.lastName || "",
email: guard.email || "",
badgeNumber: guard.badgeNumber, badgeNumber: guard.badgeNumber,
phoneNumber: guard.phoneNumber || "", phoneNumber: guard.phoneNumber || "",
location: guard.location || "roccapiemonte",
isArmed: guard.isArmed, isArmed: guard.isArmed,
hasFireSafety: guard.hasFireSafety, hasFireSafety: guard.hasFireSafety,
hasFirstAid: guard.hasFirstAid, hasFirstAid: guard.hasFirstAid,
@ -146,6 +159,50 @@ export default function Guards() {
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" {...field} data-testid="input-first-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" {...field} data-testid="input-last-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="mario.rossi@esempio.it" type="email" {...field} value={field.value || ""} data-testid="input-email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="badgeNumber" name="badgeNumber"
@ -165,7 +222,7 @@ export default function Guards() {
name="phoneNumber" name="phoneNumber"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Telefono</FormLabel> <FormLabel>Cellulare</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-phone" /> <Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-phone" />
</FormControl> </FormControl>
@ -174,6 +231,29 @@ export default function Guards() {
)} )}
/> />
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede di Appartenenza</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Competenze</p> <p className="text-sm font-medium">Competenze</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -262,11 +342,55 @@ export default function Guards() {
<DialogHeader> <DialogHeader>
<DialogTitle>Modifica Guardia</DialogTitle> <DialogTitle>Modifica Guardia</DialogTitle>
<DialogDescription> <DialogDescription>
Modifica i dati della guardia {editingGuard?.user?.firstName} {editingGuard?.user?.lastName} Modifica i dati della guardia {editingGuard?.firstName} {editingGuard?.lastName}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...editForm}> <Form {...editForm}>
<form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4"> <form onSubmit={editForm.handleSubmit(onEditSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" {...field} data-testid="input-edit-first-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" {...field} data-testid="input-edit-last-name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="mario.rossi@esempio.it" type="email" {...field} value={field.value || ""} data-testid="input-edit-email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={editForm.control} control={editForm.control}
name="badgeNumber" name="badgeNumber"
@ -286,7 +410,7 @@ export default function Guards() {
name="phoneNumber" name="phoneNumber"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Telefono</FormLabel> <FormLabel>Cellulare</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-edit-phone" /> <Input placeholder="+39 123 456 7890" {...field} value={field.value || ""} data-testid="input-edit-phone" />
</FormControl> </FormControl>
@ -295,6 +419,29 @@ export default function Guards() {
)} )}
/> />
<FormField
control={editForm.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede di Appartenenza</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger data-testid="select-edit-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Competenze</p> <p className="text-sm font-medium">Competenze</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -391,15 +538,20 @@ export default function Guards() {
<Avatar> <Avatar>
<AvatarImage src={guard.user?.profileImageUrl || undefined} /> <AvatarImage src={guard.user?.profileImageUrl || undefined} />
<AvatarFallback> <AvatarFallback>
{guard.user?.firstName?.[0]}{guard.user?.lastName?.[0]} {guard.firstName?.[0]}{guard.lastName?.[0]}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate"> <CardTitle className="text-lg truncate">
{guard.user?.firstName} {guard.user?.lastName} {guard.firstName} {guard.lastName}
</CardTitle> </CardTitle>
<CardDescription className="font-mono text-xs"> <CardDescription className="space-y-0.5">
{guard.badgeNumber} <div className="font-mono text-xs">{guard.badgeNumber}</div>
{guard.email && <div className="text-xs truncate">{guard.email}</div>}
{guard.phoneNumber && <div className="text-xs">{guard.phoneNumber}</div>}
<Badge variant="outline" className="text-xs mt-1">
{guard.location === "roccapiemonte" ? "Roccapiemonte" : guard.location === "milano" ? "Milano" : "Roma"}
</Badge>
</CardDescription> </CardDescription>
</div> </div>
<Button <Button

View File

@ -0,0 +1,244 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar, MapPin, Clock, Shield, Car, ChevronLeft, ChevronRight } from "lucide-react";
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
import { it } from "date-fns/locale";
interface ShiftAssignment {
id: string;
shiftId: string;
plannedStartTime: string;
plannedEndTime: string;
armed: boolean;
vehicleId: string | null;
vehiclePlate: string | null;
site: {
id: string;
name: string;
address: string;
location: string;
};
shift: {
shiftDate: string;
startTime: string;
endTime: string;
};
}
export default function MyShiftsFixed() {
// Data iniziale: inizio settimana corrente
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
const today = new Date();
return startOfWeek(today, { weekStartsOn: 1 });
});
// Query per recuperare i turni fissi della guardia loggata
const { data: user } = useQuery<any>({
queryKey: ["/api/auth/user"],
});
const { data: myShifts, isLoading } = useQuery<ShiftAssignment[]>({
queryKey: ["/api/my-shifts/fixed", currentWeekStart.toISOString()],
queryFn: async () => {
const weekEnd = addDays(currentWeekStart, 6);
const response = await fetch(
`/api/my-shifts/fixed?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
);
if (!response.ok) throw new Error("Failed to fetch shifts");
return response.json();
},
enabled: !!user,
});
// Naviga settimana precedente
const handlePreviousWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, -7));
};
// Naviga settimana successiva
const handleNextWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, 7));
};
// Raggruppa i turni per giorno
const shiftsByDay = myShifts?.reduce((acc, shift) => {
const date = shift.shift.shiftDate;
if (!acc[date]) acc[date] = [];
acc[date].push(shift);
return acc;
}, {} as Record<string, ShiftAssignment[]>) || {};
// Genera array di 7 giorni della settimana
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
return (
<div className="h-full flex flex-col p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-fixed">
I Miei Turni Fissi
</h1>
<p className="text-sm text-muted-foreground">
Visualizza i tuoi turni con orari e dotazioni operative
</p>
</div>
</div>
{/* Navigazione settimana */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={handlePreviousWeek}
data-testid="button-prev-week"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Settimana Precedente
</Button>
<CardTitle className="text-center">
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleNextWeek}
data-testid="button-next-week"
>
Settimana Successiva
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
</Card>
{/* Loading state */}
{isLoading && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Caricamento turni...
</CardContent>
</Card>
)}
{/* Griglia giorni settimana */}
{!isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{weekDays.map((day) => {
const dateStr = format(day, "yyyy-MM-dd");
const dayShifts = shiftsByDay[dateStr] || [];
return (
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
<CardHeader className="pb-3">
<CardTitle className="text-lg">
{format(day, "EEEE dd/MM", { locale: it })}
</CardTitle>
<CardDescription>
{dayShifts.length === 0
? "Nessun turno"
: `${dayShifts.length} turno${dayShifts.length > 1 ? "i" : ""}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{dayShifts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
Riposo
</div>
) : (
dayShifts.map((shift) => {
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
let startTime = "N/A";
let endTime = "N/A";
if (shift.plannedStartTime) {
const parsedStart = new Date(shift.plannedStartTime);
if (isValid(parsedStart)) {
startTime = parsedStart.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
if (shift.plannedEndTime) {
const parsedEnd = new Date(shift.plannedEndTime);
if (isValid(parsedEnd)) {
endTime = parsedEnd.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
return (
<div
key={shift.id}
className="border rounded-lg p-3 space-y-2"
data-testid={`shift-${shift.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<p className="font-semibold text-sm">{shift.site.name}</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
<span>{locationLabels[shift.site.location] || shift.site.location}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{startTime} - {endTime}
</span>
</div>
{/* Dotazioni */}
<div className="flex gap-2 flex-wrap">
{shift.armed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{shift.vehicleId && (
<Badge variant="outline" className="text-xs">
<Car className="h-3 w-3 mr-1" />
{shift.vehiclePlate || "Automezzo"}
</Badge>
)}
</div>
<div className="pt-1 border-t text-xs text-muted-foreground">
{shift.site.address}
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,247 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar, MapPin, Navigation, ChevronLeft, ChevronRight } from "lucide-react";
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
import { it } from "date-fns/locale";
interface PatrolRouteStop {
siteId: string;
siteName: string;
siteAddress: string;
sequenceOrder: number;
latitude: string | null;
longitude: string | null;
}
interface PatrolRoute {
id: string;
shiftDate: string;
startTime: string;
endTime: string;
location: string;
status: string;
vehicleId: string | null;
vehiclePlate: string | null;
stops: PatrolRouteStop[];
}
export default function MyShiftsMobile() {
// Data iniziale: inizio settimana corrente
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
const today = new Date();
return startOfWeek(today, { weekStartsOn: 1 });
});
// Query per recuperare i turni mobile della guardia loggata
const { data: user } = useQuery<any>({
queryKey: ["/api/auth/user"],
});
const { data: myRoutes, isLoading } = useQuery<PatrolRoute[]>({
queryKey: ["/api/my-shifts/mobile", currentWeekStart.toISOString()],
queryFn: async () => {
const weekEnd = addDays(currentWeekStart, 6);
const response = await fetch(
`/api/my-shifts/mobile?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
);
if (!response.ok) throw new Error("Failed to fetch patrol routes");
return response.json();
},
enabled: !!user,
});
// Naviga settimana precedente
const handlePreviousWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, -7));
};
// Naviga settimana successiva
const handleNextWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, 7));
};
// Raggruppa i patrol routes per giorno
const routesByDay = myRoutes?.reduce((acc, route) => {
const date = route.shiftDate;
if (!acc[date]) acc[date] = [];
acc[date].push(route);
return acc;
}, {} as Record<string, PatrolRoute[]>) || {};
// Genera array di 7 giorni della settimana
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
const statusLabels: Record<string, string> = {
planned: "Pianificato",
in_progress: "In Corso",
completed: "Completato",
cancelled: "Annullato",
};
const statusColors: Record<string, string> = {
planned: "bg-blue-500/10 text-blue-500 border-blue-500/20",
in_progress: "bg-green-500/10 text-green-500 border-green-500/20",
completed: "bg-gray-500/10 text-gray-500 border-gray-500/20",
cancelled: "bg-red-500/10 text-red-500 border-red-500/20",
};
return (
<div className="h-full flex flex-col p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" data-testid="title-my-shifts-mobile">
I Miei Turni Pattuglia
</h1>
<p className="text-sm text-muted-foreground">
Visualizza i tuoi percorsi di pattuglia con sequenza tappe
</p>
</div>
</div>
{/* Navigazione settimana */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={handlePreviousWeek}
data-testid="button-prev-week"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Settimana Precedente
</Button>
<CardTitle className="text-center">
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleNextWeek}
data-testid="button-next-week"
>
Settimana Successiva
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
</Card>
{/* Loading state */}
{isLoading && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Caricamento turni pattuglia...
</CardContent>
</Card>
)}
{/* Griglia giorni settimana */}
{!isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{weekDays.map((day) => {
const dateStr = format(day, "yyyy-MM-dd");
const dayRoutes = routesByDay[dateStr] || [];
return (
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
<CardHeader className="pb-3">
<CardTitle className="text-lg">
{format(day, "EEEE dd/MM", { locale: it })}
</CardTitle>
<CardDescription>
{dayRoutes.length === 0
? "Nessuna pattuglia"
: `${dayRoutes.length} pattuglia${dayRoutes.length > 1 ? "e" : ""}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{dayRoutes.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
Riposo
</div>
) : (
dayRoutes.map((route) => (
<div
key={route.id}
className="border rounded-lg p-3 space-y-3"
data-testid={`patrol-route-${route.id}`}
>
{/* Header pattuglia */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<Navigation className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold text-sm">
Pattuglia {locationLabels[route.location]}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />
<span>{route.stops.length} tappe</span>
</div>
</div>
<Badge
variant="outline"
className={statusColors[route.status] || ""}
>
{statusLabels[route.status] || route.status}
</Badge>
</div>
{/* Sequenza tappe */}
<div className="space-y-2 pl-4 border-l-2 border-muted">
{route.stops
.sort((a, b) => a.sequenceOrder - b.sequenceOrder)
.map((stop, index) => (
<div
key={stop.siteId}
className="space-y-1"
data-testid={`stop-${index}`}
>
<div className="flex items-start gap-2">
<Badge className="bg-green-600 h-5 w-5 p-0 flex items-center justify-center text-xs">
{stop.sequenceOrder}
</Badge>
<div className="flex-1 space-y-0.5">
<p className="text-sm font-medium leading-tight">
{stop.siteName}
</p>
<p className="text-xs text-muted-foreground leading-tight">
{stop.siteAddress}
</p>
</div>
</div>
</div>
))}
</div>
{/* Info veicolo */}
{route.vehiclePlate && (
<div className="pt-2 border-t text-xs text-muted-foreground">
Automezzo: {route.vehiclePlate}
</div>
)}
</div>
))
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,600 @@
import { useState, useEffect } 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Calendar, AlertCircle, CheckCircle2, Clock, MapPin, Users, Shield, Car as CarIcon, Building2 } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { format } from "date-fns";
import { it } from "date-fns/locale";
import { useToast } from "@/hooks/use-toast";
import { useLocation } from "wouter";
interface Shift {
id: string;
startTime: string;
endTime: string;
assignedGuardsCount: number;
requiredGuards: number;
isCovered: boolean;
isPartial: boolean;
}
interface UncoveredSite {
id: string;
name: string;
address: string;
location: string;
shiftType: string;
minGuards: number;
requiresArmed: boolean;
requiresDriverLicense: boolean;
serviceStartTime: string | null;
serviceEndTime: string | null;
isCovered: boolean;
isPartiallyCovered: boolean;
totalAssignedGuards: number;
requiredGuards: number;
shiftsCount: number;
shifts: Shift[];
}
interface UncoveredSitesData {
date: string;
uncoveredSites: UncoveredSite[];
totalSites: number;
totalUncovered: number;
}
interface Vehicle {
id: string;
licensePlate: string;
brand: string;
model: string;
vehicleType: string;
location: string;
hasDriverLicense?: boolean;
isAvailable: boolean;
}
interface Guard {
id: string;
badgeNumber: string;
userId: string;
firstName?: string;
lastName?: string;
location: string;
isArmed: boolean;
hasDriverLicense: boolean;
isAvailable: boolean;
availability: {
weeklyHours: number;
remainingWeeklyHours: number;
consecutiveDaysWorked: number;
};
}
interface ResourcesData {
date: string;
vehicles: Vehicle[];
guards: Guard[];
}
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
export default function OperationalPlanning() {
const { toast } = useToast();
const [location] = useLocation();
// Leggi parametri dalla URL
const searchParams = new URLSearchParams(location.split('?')[1] || '');
const urlDate = searchParams.get('date');
const urlLocation = searchParams.get('location');
const [selectedLocation, setSelectedLocation] = useState<string>(
urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)
? urlLocation
: "roccapiemonte"
);
const [selectedDate, setSelectedDate] = useState<string>(
urlDate || format(new Date(), "yyyy-MM-dd")
);
const [selectedSite, setSelectedSite] = useState<UncoveredSite | null>(null);
const [selectedGuards, setSelectedGuards] = useState<string[]>([]);
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
const [createShiftDialogOpen, setCreateShiftDialogOpen] = useState(false);
// Aggiorna stato quando cambiano i parametri URL
useEffect(() => {
if (urlDate) setSelectedDate(urlDate);
if (urlLocation && ['roccapiemonte', 'milano', 'roma'].includes(urlLocation)) {
setSelectedLocation(urlLocation);
}
}, [urlDate, urlLocation]);
// Query per siti non coperti (filtrati per sede e data)
const { data: uncoveredData, isLoading } = useQuery<UncoveredSitesData>({
queryKey: ['/api/operational-planning/uncovered-sites', selectedDate, selectedLocation],
queryFn: async ({ queryKey }) => {
const [, date, location] = queryKey;
const res = await fetch(`/api/operational-planning/uncovered-sites?date=${date}&location=${location}`, {
credentials: 'include'
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
},
enabled: !!selectedDate && !!selectedLocation,
});
// Query per risorse (veicoli e guardie) - solo quando c'è un sito selezionato
const { data: resourcesData, isLoading: isLoadingResources } = useQuery<ResourcesData>({
queryKey: ['/api/operational-planning/availability', selectedDate, selectedLocation, selectedSite?.id],
queryFn: async ({ queryKey }) => {
const [, date, location] = queryKey;
const res = await fetch(`/api/operational-planning/availability?date=${date}&location=${location}`, {
credentials: 'include'
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
},
enabled: !!selectedDate && !!selectedLocation && !!selectedSite,
});
const handleLocationChange = (location: string) => {
setSelectedLocation(location);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
};
const handleSelectSite = (site: UncoveredSite) => {
setSelectedSite(site);
setSelectedGuards([]);
setSelectedVehicle(null);
setCreateShiftDialogOpen(true);
};
const toggleGuardSelection = (guardId: string) => {
setSelectedGuards((prev) =>
prev.includes(guardId)
? prev.filter((id) => id !== guardId)
: [...prev, guardId]
);
};
// Filtra risorse per requisiti del sito
const filteredVehicles = resourcesData?.vehicles.filter((v) => {
if (!selectedSite) return false;
// Filtra per sede e disponibilità
if (v.location !== selectedSite.location) return false;
if (!v.isAvailable) return false;
return true;
}) || [];
const filteredGuards = resourcesData?.guards.filter((g) => {
if (!selectedSite) return false;
// Filtra per sede
if (g.location !== selectedSite.location) return false;
// Filtra per disponibilità
if (!g.isAvailable) return false;
// Filtra per requisiti
if (selectedSite.requiresArmed && !g.isArmed) return false;
if (selectedSite.requiresDriverLicense && !g.hasDriverLicense) return false;
return true;
}) || [];
// Mutation per creare turno
const createShiftMutation = useMutation({
mutationFn: async (data: any) => {
return apiRequest("POST", "/api/shifts", data);
},
onSuccess: () => {
// Invalida tutte le query che iniziano con /api/operational-planning/uncovered-sites
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0]?.toString().startsWith('/api/operational-planning/uncovered-sites') || false
});
toast({
title: "Turno creato",
description: "Il turno è stato creato con successo.",
});
setCreateShiftDialogOpen(false);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare il turno.",
variant: "destructive",
});
},
});
const handleCreateShift = () => {
if (!selectedSite) return;
// Valida che ci siano abbastanza guardie selezionate
if (selectedGuards.length < selectedSite.minGuards) {
toast({
title: "Guardie insufficienti",
description: `Seleziona almeno ${selectedSite.minGuards} guardie per questo sito.`,
variant: "destructive",
});
return;
}
// TODO: Qui bisognerà chiedere l'orario del turno
// Per ora creiamo un turno di default basato su serviceStartTime/serviceEndTime del sito
const today = selectedDate;
const startTime = selectedSite.serviceStartTime || "08:00";
const endTime = selectedSite.serviceEndTime || "16:00";
const shiftData = {
siteId: selectedSite.id,
startTime: new Date(`${today}T${startTime}:00.000Z`),
endTime: new Date(`${today}T${endTime}:00.000Z`),
status: "planned",
vehicleId: selectedVehicle || null,
guardIds: selectedGuards,
};
createShiftMutation.mutate(shiftData);
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Calendar className="h-8 w-8" />
Pianificazione Operativa
</h1>
<p className="text-muted-foreground mt-1">
Assegna turni ai siti non coperti
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Seleziona Sede e Data
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-4 items-end">
<div className="flex-1 max-w-xs">
<Label htmlFor="planning-location">Sede</Label>
<Select value={selectedLocation} onValueChange={handleLocationChange}>
<SelectTrigger id="planning-location" data-testid="select-planning-location" className="mt-1">
<Building2 className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 max-w-xs">
<Label htmlFor="planning-date">Data</Label>
<Input
id="planning-date"
data-testid="input-planning-date"
type="date"
value={selectedDate}
onChange={handleDateChange}
className="mt-1"
/>
</div>
</div>
{uncoveredData && (
<div className="mt-4 flex items-center gap-4 text-sm">
<p className="text-muted-foreground">
{format(new Date(selectedDate), "dd MMMM yyyy", { locale: it })}
</p>
<Badge variant="outline" className="gap-1">
<AlertCircle className="h-3 w-3" />
{uncoveredData.totalUncovered} siti da coprire
</Badge>
</div>
)}
</CardContent>
</Card>
{/* Lista siti non coperti */}
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : uncoveredData && uncoveredData.uncoveredSites.length > 0 ? (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Siti Non Coperti</h2>
{uncoveredData.uncoveredSites.map((site) => (
<Card key={site.id} data-testid={`site-card-${site.id}`}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{site.name}
{site.isPartiallyCovered ? (
<Badge variant="secondary" className="gap-1">
<AlertCircle className="h-3 w-3" />
Parzialmente Coperto
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Non Coperto
</Badge>
)}
</CardTitle>
<CardDescription className="mt-2 space-y-1">
<div className="flex items-center gap-1 text-sm">
<MapPin className="h-3 w-3" />
{site.address} - {site.location}
</div>
{site.serviceStartTime && site.serviceEndTime && (
<div className="flex items-center gap-1 text-sm">
<Clock className="h-3 w-3" />
Orario: {site.serviceStartTime} - {site.serviceEndTime}
</div>
)}
</CardDescription>
</div>
<Button
onClick={() => handleSelectSite(site)}
data-testid={`button-select-site-${site.id}`}
>
Assegna Turno
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Turni Pianificati</p>
<p className="text-lg font-semibold">{site.shiftsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Richieste</p>
<p className="text-lg font-semibold">{site.requiredGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Guardie Assegnate</p>
<p className="text-lg font-semibold">{site.totalAssignedGuards}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Requisiti</p>
<div className="flex gap-1 mt-1">
{site.requiresArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{site.requiresDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div>
</div>
</div>
{site.shifts.length > 0 && (
<div className="mt-4 pt-4 border-t">
<p className="text-sm font-medium mb-2">Dettagli Turni:</p>
<div className="space-y-2">
{site.shifts.map((shift) => (
<div key={shift.id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
{format(new Date(shift.startTime), "HH:mm")} - {format(new Date(shift.endTime), "HH:mm")}
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4" />
{shift.assignedGuardsCount}/{shift.requiredGuards}
{shift.isCovered ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : shift.isPartial ? (
<AlertCircle className="h-4 w-4 text-orange-500" />
) : (
<AlertCircle className="h-4 w-4 text-red-500" />
)}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-12 text-center">
<CheckCircle2 className="h-12 w-12 mx-auto text-green-500 mb-4" />
<h3 className="text-lg font-semibold mb-2">Tutti i siti sono coperti!</h3>
<p className="text-muted-foreground">
Non ci sono siti che richiedono assegnazioni per questa data.
</p>
</CardContent>
</Card>
)}
{/* Dialog per assegnare risorse e creare turno */}
<Dialog open={createShiftDialogOpen} onOpenChange={setCreateShiftDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Assegna Risorse - {selectedSite?.name}</DialogTitle>
<DialogDescription>
Seleziona le guardie e il veicolo per creare il turno
</DialogDescription>
</DialogHeader>
{isLoadingResources ? (
<div className="space-y-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
) : (
<div className="space-y-6">
{/* Veicoli Disponibili */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<CarIcon className="h-5 w-5" />
Veicoli Disponibili ({filteredVehicles.length})
</h3>
{filteredVehicles.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{filteredVehicles.map((vehicle) => (
<Card
key={vehicle.id}
className={`cursor-pointer transition-colors ${
selectedVehicle === vehicle.id ? "border-primary bg-primary/5" : ""
}`}
onClick={() => setSelectedVehicle(vehicle.id)}
data-testid={`vehicle-card-${vehicle.id}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{vehicle.licensePlate}</p>
<p className="text-sm text-muted-foreground">
{vehicle.brand} {vehicle.model}
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedVehicle === vehicle.id}
onCheckedChange={() => setSelectedVehicle(vehicle.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Nessun veicolo disponibile per questa sede</p>
)}
</div>
{/* Guardie Disponibili */}
<div>
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Users className="h-5 w-5" />
Guardie Disponibili ({filteredGuards.length})
<Badge variant="outline" className="ml-auto">
Selezionate: {selectedGuards.length}/{selectedSite?.minGuards || 0}
</Badge>
</h3>
{filteredGuards.length > 0 ? (
<div className="space-y-2">
{filteredGuards.map((guard) => (
<Card
key={guard.id}
className={`cursor-pointer transition-colors ${
selectedGuards.includes(guard.id) ? "border-primary bg-primary/5" : ""
}`}
onClick={() => toggleGuardSelection(guard.id)}
data-testid={`guard-card-${guard.id}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
</p>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{guard.hasDriverLicense && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Ore sett.: {guard.availability.weeklyHours}h | Rimaste: {guard.availability.remainingWeeklyHours}h
</p>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedGuards.includes(guard.id)}
onCheckedChange={() => toggleGuardSelection(guard.id)}
/>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Nessuna guardia disponibile che soddisfa i requisiti del sito
</p>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setCreateShiftDialogOpen(false);
setSelectedSite(null);
setSelectedGuards([]);
setSelectedVehicle(null);
}}
data-testid="button-cancel-shift"
>
Annulla
</Button>
<Button
onClick={handleCreateShift}
disabled={
!selectedSite ||
selectedGuards.length < (selectedSite?.minGuards || 0) ||
createShiftMutation.isPending
}
data-testid="button-create-shift"
>
{createShiftMutation.isPending ? "Creazione..." : "Crea Turno"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,227 +1,619 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ShiftWithDetails, Guard } from "@shared/schema";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { BarChart3, Users, Clock, Calendar, TrendingUp } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { differenceInHours, format, startOfMonth, endOfMonth } from "date-fns"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Download, Users, Building2, Clock, TrendingUp } from "lucide-react";
import { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
type Location = "roccapiemonte" | "milano" | "roma";
interface GuardReport {
guardId: string;
guardName: string;
badgeNumber: string;
ordinaryHours: number;
overtimeHours: number;
totalHours: number;
mealVouchers: number;
workingDays: number;
}
interface SiteReport {
siteId: string;
siteName: string;
serviceTypes: {
name: string;
hours: number;
shifts: number;
}[];
totalHours: number;
totalShifts: number;
}
interface CustomerReport {
customerId: string;
customerName: string;
sites: {
siteId: string;
siteName: string;
serviceTypes: {
name: string;
hours: number;
shifts: number;
passages: number;
inspections: number;
interventions: number;
}[];
totalHours: number;
totalShifts: number;
}[];
totalHours: number;
totalShifts: number;
totalPatrolPassages: number;
totalInspections: number;
totalInterventions: number;
}
export default function Reports() { export default function Reports() {
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({ const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
queryKey: ["/api/shifts"], const [selectedMonth, setSelectedMonth] = useState<string>(format(new Date(), "yyyy-MM"));
// Query per report guardie
const { data: guardReport, isLoading: isLoadingGuards } = useQuery<{
month: string;
location: string;
guards: GuardReport[];
summary: {
totalGuards: number;
totalOrdinaryHours: number;
totalOvertimeHours: number;
totalHours: number;
totalMealVouchers: number;
};
}>({
queryKey: ["/api/reports/monthly-guard-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/monthly-guard-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch guard report");
return response.json();
},
}); });
const { data: guards, isLoading: guardsLoading } = useQuery<Guard[]>({ // Query per report siti
queryKey: ["/api/guards"], const { data: siteReport, isLoading: isLoadingSites } = useQuery<{
month: string;
location: string;
sites: SiteReport[];
summary: {
totalSites: number;
totalHours: number;
totalShifts: number;
};
}>({
queryKey: ["/api/reports/billable-site-hours", selectedMonth, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/reports/billable-site-hours?month=${selectedMonth}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site report");
return response.json();
},
}); });
const isLoading = shiftsLoading || guardsLoading; // Query per report clienti
const { data: customerReport, isLoading: isLoadingCustomers } = useQuery<{
// Calculate statistics month: string;
const completedShifts = shifts?.filter(s => s.status === "completed") || []; location: string;
const totalHours = completedShifts.reduce((acc, shift) => { customers: CustomerReport[];
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); summary: {
}, 0); totalCustomers: number;
totalHours: number;
// Hours per guard totalShifts: number;
const hoursPerGuard: Record<string, { name: string; hours: number }> = {}; totalPatrolPassages: number;
completedShifts.forEach(shift => { totalInspections: number;
shift.assignments.forEach(assignment => { totalInterventions: number;
const guardId = assignment.guardId; };
const guardName = `${assignment.guard.user?.firstName || ""} ${assignment.guard.user?.lastName || ""}`.trim(); }>({
const hours = differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); queryKey: ["/api/reports/customer-billing", selectedMonth, selectedLocation],
queryFn: async () => {
if (!hoursPerGuard[guardId]) { const response = await fetch(`/api/reports/customer-billing?month=${selectedMonth}&location=${selectedLocation}`);
hoursPerGuard[guardId] = { name: guardName, hours: 0 }; if (!response.ok) throw new Error("Failed to fetch customer report");
} return response.json();
hoursPerGuard[guardId].hours += hours; },
});
}); });
const guardStats = Object.values(hoursPerGuard).sort((a, b) => b.hours - a.hours); // Genera mesi disponibili (ultimi 12 mesi)
const availableMonths = Array.from({ length: 12 }, (_, i) => {
// Monthly statistics const date = new Date();
const currentMonth = new Date(); date.setMonth(date.getMonth() - i);
const monthStart = startOfMonth(currentMonth); return format(date, "yyyy-MM");
const monthEnd = endOfMonth(currentMonth);
const monthlyShifts = completedShifts.filter(s => {
const shiftDate = new Date(s.startTime);
return shiftDate >= monthStart && shiftDate <= monthEnd;
}); });
const monthlyHours = monthlyShifts.reduce((acc, shift) => { // Export CSV guardie
return acc + differenceInHours(new Date(shift.endTime), new Date(shift.startTime)); const exportGuardsCSV = () => {
}, 0); if (!guardReport?.guards) return;
const headers = "Guardia,Badge,Ore Ordinarie,Ore Straordinarie,Ore Totali,Buoni Pasto,Giorni Lavorativi\n";
const rows = guardReport.guards.map(g =>
`"${g.guardName}",${g.badgeNumber},${g.ordinaryHours},${g.overtimeHours},${g.totalHours},${g.mealVouchers},${g.workingDays}`
).join("\n");
const csv = headers + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ore_guardie_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
// Export CSV siti
const exportSitesCSV = () => {
if (!siteReport?.sites) return;
const headers = "Sito,Tipologia Servizio,Ore,Turni\n";
const rows = siteReport.sites.flatMap(s =>
s.serviceTypes.map(st =>
`"${s.siteName}","${st.name}",${st.hours},${st.shifts}`
)
).join("\n");
const csv = headers + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ore_siti_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
// Export CSV clienti
const exportCustomersCSV = () => {
if (!customerReport?.customers) return;
const headers = "Cliente,Sito,Tipologia Servizio,Ore,Turni,Passaggi,Ispezioni,Interventi\n";
const rows = customerReport.customers.flatMap(c =>
c.sites.flatMap(s =>
s.serviceTypes.map(st =>
`"${c.customerName}","${s.siteName}","${st.name}",${st.hours},${st.shifts},${st.passages},${st.inspections},${st.interventions}`
)
)
).join("\n");
const csv = headers + rows;
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `fatturazione_clienti_${selectedMonth}_${selectedLocation}.csv`;
a.click();
};
return ( return (
<div className="space-y-6"> <div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div> <div>
<h1 className="text-3xl font-semibold mb-2">Report e Statistiche</h1> <h1 className="text-3xl font-bold">Report e Export</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Ore lavorate e copertura servizi Ore lavorate, buoni pasto e fatturazione
</p> </p>
</div> </div>
{isLoading ? ( {/* Filtri */}
<div className="grid gap-4 md:grid-cols-3"> <Card>
<Skeleton className="h-32" /> <CardContent className="pt-6">
<Skeleton className="h-32" /> <div className="flex flex-wrap items-center gap-4">
<Skeleton className="h-32" /> <div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div> </div>
) : (
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Mese</label>
<Select value={selectedMonth} onValueChange={setSelectedMonth}>
<SelectTrigger data-testid="select-month">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableMonths.map(month => {
const [year, monthNum] = month.split("-");
const date = new Date(parseInt(year), parseInt(monthNum) - 1);
return (
<SelectItem key={month} value={month}>
{format(date, "MMMM yyyy", { locale: it })}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Tabs Report */}
<Tabs defaultValue="guards" className="space-y-6">
<TabsList className="grid w-full max-w-[600px] grid-cols-3">
<TabsTrigger value="guards" data-testid="tab-guard-report">
<Users className="h-4 w-4 mr-2" />
Report Guardie
</TabsTrigger>
<TabsTrigger value="sites" data-testid="tab-site-report">
<Building2 className="h-4 w-4 mr-2" />
Report Siti
</TabsTrigger>
<TabsTrigger value="customers" data-testid="tab-customer-report">
<Building2 className="h-4 w-4 mr-2" />
Report Clienti
</TabsTrigger>
</TabsList>
{/* Tab Report Guardie */}
<TabsContent value="guards" className="space-y-4">
{isLoadingGuards ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : guardReport ? (
<> <>
{/* Summary Cards */} {/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Totali Lavorate
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-total-hours">
{totalHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{completedShifts.length} turni completati
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Ore Mese Corrente
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold" data-testid="text-monthly-hours">
{monthlyHours}h
</p>
<p className="text-xs text-muted-foreground mt-1">
{format(currentMonth, "MMMM yyyy", { locale: it })}
</p>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2"> <CardDescription className="flex items-center gap-2">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
Guardie Attive Guardie
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-3xl font-semibold" data-testid="text-active-guards"> <p className="text-2xl font-semibold">{guardReport.summary.totalGuards}</p>
{guardStats.length} </CardContent>
</p> </Card>
<p className="text-xs text-muted-foreground mt-1">
Con turni completati <Card>
</p> <CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Ordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOrdinaryHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Ore Straordinarie
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalOvertimeHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Buoni Pasto</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{guardReport.summary.totalMealVouchers}</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Hours per Guard */} {/* Tabella guardie */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <div className="flex items-center justify-between">
<BarChart3 className="h-5 w-5" /> <div>
Ore per Guardia <CardTitle>Dettaglio Ore per Guardia</CardTitle>
</CardTitle> <CardDescription>Ordinarie, straordinarie e buoni pasto</CardDescription>
<CardDescription> </div>
Ore totali lavorate per ogni guardia <Button onClick={exportGuardsCSV} data-testid="button-export-guards">
</CardDescription> <Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{guardStats.length > 0 ? ( {guardReport.guards.length > 0 ? (
<div className="space-y-3"> <div className="overflow-x-auto">
{guardStats.map((stat, index) => ( <table className="w-full">
<div <thead>
key={index} <tr className="border-b">
className="flex items-center gap-4" <th className="text-left p-3 font-medium">Guardia</th>
data-testid={`guard-stat-${index}`} <th className="text-left p-3 font-medium">Badge</th>
> <th className="text-right p-3 font-medium">Ore Ord.</th>
<div className="flex-1 min-w-0"> <th className="text-right p-3 font-medium">Ore Strao.</th>
<p className="font-medium truncate">{stat.name}</p> <th className="text-right p-3 font-medium">Totale</th>
<div className="mt-1 h-2 bg-secondary rounded-full overflow-hidden"> <th className="text-center p-3 font-medium">Buoni Pasto</th>
<div <th className="text-center p-3 font-medium">Giorni</th>
className="h-full bg-primary" </tr>
style={{ </thead>
width: `${(stat.hours / (guardStats[0]?.hours || 1)) * 100}%`, <tbody>
}} {guardReport.guards.map((guard) => (
/> <tr key={guard.guardId} className="border-b hover:bg-muted/50" data-testid={`guard-row-${guard.guardId}`}>
</div> <td className="p-3">{guard.guardName}</td>
</div> <td className="p-3"><Badge variant="outline">{guard.badgeNumber}</Badge></td>
<div className="text-right"> <td className="p-3 text-right font-mono">{guard.ordinaryHours}h</td>
<p className="text-lg font-semibold font-mono">{stat.hours}h</p> <td className="p-3 text-right font-mono text-orange-600 dark:text-orange-500">
</div> {guard.overtimeHours > 0 ? `${guard.overtimeHours}h` : "-"}
</div> </td>
<td className="p-3 text-right font-mono font-semibold">{guard.totalHours}h</td>
<td className="p-3 text-center">{guard.mealVouchers}</td>
<td className="p-3 text-center text-muted-foreground">{guard.workingDays}</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <p className="text-center text-muted-foreground py-8">Nessuna guardia con ore lavorate</p>
<BarChart3 className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun dato disponibile
</p>
</div>
)}
</CardContent>
</Card>
{/* Recent Shifts Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Turni Recenti
</CardTitle>
<CardDescription>
Ultimi turni completati
</CardDescription>
</CardHeader>
<CardContent>
{completedShifts.length > 0 ? (
<div className="space-y-3">
{completedShifts.slice(0, 5).map((shift) => (
<div
key={shift.id}
className="flex items-center justify-between p-3 rounded-md border"
data-testid={`recent-shift-${shift.id}`}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{shift.site.name}</p>
<p className="text-sm text-muted-foreground">
{format(new Date(shift.startTime), "dd MMM yyyy", { locale: it })}
</p>
</div>
<div className="text-right">
<p className="font-mono text-sm">
{differenceInHours(new Date(shift.endTime), new Date(shift.startTime))}h
</p>
<p className="text-xs text-muted-foreground">
{shift.assignments.length} guardie
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Nessun turno completato
</p>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</> </>
) : null}
</TabsContent>
{/* Tab Report Siti */}
<TabsContent value="sites" className="space-y-4">
{isLoadingSites ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : siteReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Siti Attivi
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalSites}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Fatturabili
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Turni Totali</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{siteReport.summary.totalShifts}</p>
</CardContent>
</Card>
</div>
{/* Tabella siti */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ore Fatturabili per Sito</CardTitle>
<CardDescription>Raggruppate per tipologia servizio</CardDescription>
</div>
<Button onClick={exportSitesCSV} data-testid="button-export-sites">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{siteReport.sites.length > 0 ? (
<div className="space-y-4">
{siteReport.sites.map((site) => (
<div key={site.siteId} className="border rounded-md p-4" data-testid={`site-report-${site.siteId}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg">{site.siteName}</h3>
<div className="flex items-center gap-2">
<Badge>{site.totalHours}h totali</Badge>
<Badge variant="outline">{site.totalShifts} turni</Badge>
</div>
</div>
<div className="space-y-2">
{site.serviceTypes.map((st, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<span>{st.name}</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">{st.shifts} turni</span>
<span className="font-mono font-semibold">{st.hours}h</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessun sito con ore fatturabili</p>
)} )}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
{/* Tab Report Clienti */}
<TabsContent value="customers" className="space-y-4">
{isLoadingCustomers ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : customerReport ? (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-5">
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
Clienti
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{customerReport.summary.totalCustomers}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Ore Totali
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{customerReport.summary.totalHours}h</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Passaggi</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{customerReport.summary.totalPatrolPassages}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Ispezioni</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{customerReport.summary.totalInspections}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Interventi</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{customerReport.summary.totalInterventions}</p>
</CardContent>
</Card>
</div>
{/* Tabella clienti */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Fatturazione per Cliente</CardTitle>
<CardDescription>Dettaglio siti e servizi erogati</CardDescription>
</div>
<Button onClick={exportCustomersCSV} data-testid="button-export-customers">
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</CardHeader>
<CardContent>
{customerReport.customers.length > 0 ? (
<div className="space-y-6">
{customerReport.customers.map((customer) => (
<div key={customer.customerId} className="border-2 rounded-lg p-4" data-testid={`customer-report-${customer.customerId}`}>
{/* Header Cliente */}
<div className="flex items-center justify-between mb-4 pb-3 border-b">
<div>
<h3 className="font-semibold text-xl">{customer.customerName}</h3>
<p className="text-sm text-muted-foreground">{customer.sites.length} siti attivi</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" className="text-base px-3 py-1">{customer.totalHours}h totali</Badge>
<Badge variant="outline">{customer.totalShifts} turni</Badge>
{customer.totalPatrolPassages > 0 && (
<Badge variant="secondary">{customer.totalPatrolPassages} passaggi</Badge>
)}
{customer.totalInspections > 0 && (
<Badge variant="secondary">{customer.totalInspections} ispezioni</Badge>
)}
{customer.totalInterventions > 0 && (
<Badge variant="secondary">{customer.totalInterventions} interventi</Badge>
)}
</div>
</div>
{/* Lista Siti */}
<div className="space-y-3">
{customer.sites.map((site) => (
<div key={site.siteId} className="bg-muted/30 rounded-md p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{site.siteName}</h4>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">{site.totalHours}h</Badge>
<Badge variant="outline" className="text-xs">{site.totalShifts} turni</Badge>
</div>
</div>
<div className="space-y-1">
{site.serviceTypes.map((st, idx) => (
<div key={idx} className="flex items-center justify-between text-sm p-2 rounded bg-background">
<span className="text-muted-foreground">{st.name}</span>
<div className="flex items-center gap-3">
{st.hours > 0 && <span className="font-mono">{st.hours}h</span>}
{st.passages > 0 && (
<Badge variant="secondary" className="text-xs">{st.passages} passaggi</Badge>
)}
{st.inspections > 0 && (
<Badge variant="secondary" className="text-xs">{st.inspections} ispezioni</Badge>
)}
{st.interventions > 0 && (
<Badge variant="secondary" className="text-xs">{st.interventions} interventi</Badge>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground py-8">Nessun cliente con servizi fatturabili</p>
)}
</CardContent>
</Card>
</>
) : null}
</TabsContent>
</Tabs>
</div> </div>
); );
} }

View File

@ -0,0 +1,451 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
import { it } from "date-fns/locale";
import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Location = "roccapiemonte" | "milano" | "roma";
interface FixedShiftDetail {
shiftId: string;
date: string;
from: string;
to: string;
siteName: string;
siteAddress: string;
siteId: string;
isArmed: boolean;
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
hours: number;
}
interface FixedGuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
shifts: FixedShiftDetail[];
totalHours: number;
}
interface PatrolRoute {
routeId: string;
guardId: string;
shiftDate: string;
startTime: string;
endTime: string;
isArmedRoute: boolean;
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
stops: {
siteId: string;
siteName: string;
siteAddress: string;
sequenceOrder: number;
}[];
}
interface MobileGuardSchedule {
guardId: string;
guardName: string;
badgeNumber: string;
routes: PatrolRoute[];
totalRoutes: number;
}
interface SiteSchedule {
siteId: string;
siteName: string;
location: string;
shifts: {
shiftId: string;
date: string;
from: string;
to: string;
guards: {
guardName: string;
badgeNumber: string;
hours: number;
isArmed: boolean;
}[];
vehicle?: {
licensePlate: string;
brand: string;
model: string;
};
totalGuards: number;
totalHours: number;
}[];
totalShifts: number;
totalHours: number;
}
export default function ServicePlanning() {
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed");
const weekStartStr = format(weekStart, "yyyy-MM-dd");
// Query per vista Agenti Fissi
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
return response.json();
},
enabled: viewMode === "guard-fixed",
});
// Query per vista Agenti Mobili
const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery<MobileGuardSchedule[]>({
queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch mobile guard schedules");
return response.json();
},
enabled: viewMode === "guard-mobile",
});
// Query per vista Siti
const { data: siteSchedules, isLoading: isLoadingSites } = useQuery<SiteSchedule[]>({
queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation],
queryFn: async () => {
const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`);
if (!response.ok) throw new Error("Failed to fetch site schedules");
return response.json();
},
enabled: viewMode === "site",
});
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
return (
<div className="h-full overflow-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Planning di Servizio</h1>
<p className="text-muted-foreground">
Visualizza orari e dotazioni per agente fisso, agente mobile o per sito
</p>
</div>
</div>
{/* Controlli */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-center gap-4">
{/* Selezione sede */}
<div className="flex-1 min-w-[200px]">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
<SelectTrigger data-testid="select-location">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
{/* Navigazione settimana */}
<div className="flex-1 min-w-[300px]">
<label className="text-sm font-medium mb-2 block">Settimana</label>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={goToPreviousWeek} data-testid="button-prev-week">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-medium">
{format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })}
</div>
<Button variant="outline" size="icon" onClick={goToNextWeek} data-testid="button-next-week">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabs per vista */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
<TabsList className="grid w-full max-w-2xl grid-cols-3">
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
<Users className="h-4 w-4 mr-2" />
Agenti Fissi
</TabsTrigger>
<TabsTrigger value="guard-mobile" data-testid="tab-guard-mobile-view">
<Navigation className="h-4 w-4 mr-2" />
Agenti Mobili
</TabsTrigger>
<TabsTrigger value="site" data-testid="tab-site-view">
<Building2 className="h-4 w-4 mr-2" />
Vista Sito
</TabsTrigger>
</TabsList>
{/* Vista Agenti Fissi */}
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
{isLoadingFixedGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
<div className="grid gap-4">
{fixedGuardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-fixed-${guard.guardId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
</CardTitle>
<Badge>{guard.totalHours}h totali</Badge>
</div>
</CardHeader>
<CardContent>
{guard.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno fisso assegnato</p>
) : (
<div className="space-y-3">
{guard.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<div className="font-medium">{shift.siteName}</div>
<div className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-3 w-3" />
{shift.siteAddress}
</div>
<div className="text-sm text-muted-foreground">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to} ({shift.hours}h)
</div>
</div>
<div className="flex flex-col items-end gap-1">
{shift.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{shift.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{shift.vehicle.licensePlate}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun agente con turni fissi assegnati</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Vista Agenti Mobili */}
<TabsContent value="guard-mobile" className="space-y-4 mt-6">
{isLoadingMobileGuards ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? (
<div className="grid gap-4">
{mobileGuardSchedules.map((guard) => (
<Card key={guard.guardId} data-testid={`card-guard-mobile-${guard.guardId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
</CardTitle>
<Badge>{guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'}</Badge>
</div>
</CardHeader>
<CardContent>
{guard.routes.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun percorso pattuglia assegnato</p>
) : (
<div className="space-y-4">
{guard.routes.map((route) => (
<div
key={route.routeId}
className="p-3 rounded-md bg-muted/50 space-y-3"
data-testid={`route-${route.routeId}`}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })}
</div>
<div className="text-sm text-muted-foreground">
{route.startTime} - {route.endTime}
</div>
</div>
<div className="flex gap-2">
{route.isArmedRoute && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{route.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{route.vehicle.licensePlate}
</Badge>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-1">
<Navigation className="h-4 w-4" />
Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}):
</div>
<div className="space-y-1 pl-5">
{route.stops.map((stop) => (
<div key={stop.siteId} className="text-sm text-muted-foreground flex items-start gap-2">
<Badge variant="secondary" className="text-xs">
{stop.sequenceOrder}
</Badge>
<div className="flex-1">
<div className="font-medium text-foreground">{stop.siteName}</div>
<div className="text-xs flex items-center gap-1">
<MapPin className="h-3 w-3" />
{stop.siteAddress}
</div>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Vista Sito */}
<TabsContent value="site" className="space-y-4 mt-6">
{isLoadingSites ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : siteSchedules && siteSchedules.length > 0 ? (
<div className="grid gap-4">
{siteSchedules.map((site) => (
<Card key={site.siteId} data-testid={`card-site-${site.siteId}`}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{site.siteName}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline">{site.totalShifts} turni</Badge>
<Badge>{site.totalHours}h totali</Badge>
</div>
</div>
</CardHeader>
<CardContent>
{site.shifts.length === 0 ? (
<p className="text-sm text-muted-foreground">Nessun turno programmato</p>
) : (
<div className="space-y-3">
{site.shifts.map((shift) => (
<div
key={shift.shiftId}
className="p-3 rounded-md bg-muted/50 space-y-2"
data-testid={`shift-${shift.shiftId}`}
>
<div className="flex items-center justify-between">
<div className="font-medium">
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} {shift.from} - {shift.to}
</div>
<div className="flex gap-1">
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
{shift.vehicle && (
<Badge variant="outline" className="text-xs">
<CarIcon className="h-3 w-3 mr-1" />
{shift.vehicle.licensePlate}
</Badge>
)}
</div>
</div>
<div className="space-y-1">
{shift.guards.map((guard, idx) => (
<div key={idx} className="text-sm text-muted-foreground flex items-center justify-between">
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
{guard.isArmed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">Nessun sito con turni programmati</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -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,6 +237,7 @@ export default function Services() {
description: type.description, description: type.description,
icon: type.icon, icon: type.icon,
color: type.color, color: type.color,
classification: type.classification, // ✅ NUOVO: includi classification
isActive: type.isActive, isActive: type.isActive,
}); });
setEditTypeDialogOpen(true); setEditTypeDialogOpen(true);
@ -266,6 +269,8 @@ export default function Services() {
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed || false, requiresArmed: site.requiresArmed || false,
requiresDriverLicense: site.requiresDriverLicense || false, requiresDriverLicense: site.requiresDriverLicense || false,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive, isActive: site.isActive,
}); });
setEditDialogOpen(true); setEditDialogOpen(true);
@ -547,6 +552,45 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={createForm.control} control={createForm.control}
@ -733,6 +777,45 @@ export default function Services() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine Servizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={editForm.control} control={editForm.control}
@ -987,6 +1070,29 @@ export default function Services() {
/> />
</div> </div>
{/* ✅ NUOVO: Classification (Fisso/Mobile) */}
<FormField
control={createTypeForm.control}
name="classification"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo Pianificazione*</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger data-testid="select-type-classification">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="fisso">Fisso (Planning Fissi)</SelectItem>
<SelectItem value="mobile">Mobile (Planning Mobile)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={createTypeForm.control} control={createTypeForm.control}
name="isActive" name="isActive"
@ -1139,6 +1245,115 @@ export default function Services() {
/> />
</div> </div>
{/* ✅ 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>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editTypeForm.control}
name="fixedPostHours"
render={({ field }) => (
<FormItem>
<FormLabel>Ore Presidio Fisso</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
placeholder="es: 8, 12"
data-testid="input-edit-fixed-post-hours"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-patrol-passages"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-inspection-frequency"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editTypeForm.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-edit-response-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField <FormField
control={editTypeForm.control} control={editTypeForm.control}
name="isActive" name="isActive"

View File

@ -0,0 +1,284 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MapPin, Shield, Car, Clock, User, ChevronLeft, ChevronRight } from "lucide-react";
import { format, addDays, startOfWeek, parseISO, isValid } from "date-fns";
import { it } from "date-fns/locale";
interface GuardAssignment {
guardId: string;
guardName: string;
badgeNumber: string;
plannedStartTime: string;
plannedEndTime: string;
armed: boolean;
vehicleId: string | null;
vehiclePlate: string | null;
}
interface SiteDayPlan {
date: string;
guards: GuardAssignment[];
}
interface Site {
id: string;
name: string;
address: string;
location: string;
}
export default function SitePlanningView() {
const [selectedSiteId, setSelectedSiteId] = useState<string>("");
const [currentWeekStart, setCurrentWeekStart] = useState(() => {
const today = new Date();
return startOfWeek(today, { weekStartsOn: 1 });
});
// Query sites
const { data: sites } = useQuery<Site[]>({
queryKey: ["/api/sites"],
});
// Query site planning
const { data: sitePlanning, isLoading } = useQuery<SiteDayPlan[]>({
queryKey: ["/api/site-planning", selectedSiteId, currentWeekStart.toISOString()],
queryFn: async () => {
if (!selectedSiteId) return [];
const weekEnd = addDays(currentWeekStart, 6);
const response = await fetch(
`/api/site-planning/${selectedSiteId}?startDate=${currentWeekStart.toISOString()}&endDate=${weekEnd.toISOString()}`
);
if (!response.ok) throw new Error("Failed to fetch site planning");
return response.json();
},
enabled: !!selectedSiteId,
});
// Naviga settimana precedente
const handlePreviousWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, -7));
};
// Naviga settimana successiva
const handleNextWeek = () => {
setCurrentWeekStart(addDays(currentWeekStart, 7));
};
// Raggruppa per giorno
const planningByDay = sitePlanning?.reduce((acc, day) => {
acc[day.date] = day.guards;
return acc;
}, {} as Record<string, GuardAssignment[]>) || {};
// Genera array di 7 giorni della settimana
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
const selectedSite = sites?.find(s => s.id === selectedSiteId);
const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma",
};
return (
<div className="h-full flex flex-col p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" data-testid="title-site-planning-view">
Planning per Sito
</h1>
<p className="text-sm text-muted-foreground">
Visualizza tutti gli agenti assegnati a un sito con dotazioni
</p>
</div>
</div>
{/* Selettore sito */}
<Card>
<CardHeader>
<CardTitle>Seleziona Sito</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Select value={selectedSiteId} onValueChange={setSelectedSiteId}>
<SelectTrigger data-testid="select-site">
<SelectValue placeholder="Seleziona un sito..." />
</SelectTrigger>
<SelectContent>
{sites?.map((site) => (
<SelectItem key={site.id} value={site.id} data-testid={`site-option-${site.id}`}>
<div className="flex items-center gap-2">
<span className="font-medium">{site.name}</span>
<span className="text-xs text-muted-foreground">
({locationLabels[site.location] || site.location})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedSite && (
<div className="p-3 border rounded-lg bg-muted/20">
<p className="font-semibold">{selectedSite.name}</p>
<p className="text-sm text-muted-foreground">{selectedSite.address}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Navigazione settimana */}
{selectedSiteId && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={handlePreviousWeek}
data-testid="button-prev-week"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Settimana Precedente
</Button>
<CardTitle className="text-center">
{format(currentWeekStart, "dd MMMM", { locale: it })} -{" "}
{format(addDays(currentWeekStart, 6), "dd MMMM yyyy", { locale: it })}
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={handleNextWeek}
data-testid="button-next-week"
>
Settimana Successiva
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
</Card>
)}
{/* Loading state */}
{isLoading && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Caricamento planning sito...
</CardContent>
</Card>
)}
{/* Griglia giorni settimana */}
{selectedSiteId && !isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{weekDays.map((day) => {
const dateStr = format(day, "yyyy-MM-dd");
const dayGuards = planningByDay[dateStr] || [];
return (
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
<CardHeader className="pb-3">
<CardTitle className="text-lg">
{format(day, "EEEE dd/MM", { locale: it })}
</CardTitle>
<CardDescription>
{dayGuards.length === 0
? "Nessun agente"
: `${dayGuards.length} agente${dayGuards.length > 1 ? "i" : ""}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{dayGuards.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
Nessuna copertura
</div>
) : (
dayGuards.map((guard, index) => {
// Parsing sicuro orari (DB in UTC → visualizza in Europe/Rome)
let startTime = "N/A";
let endTime = "N/A";
if (guard.plannedStartTime) {
const parsedStart = new Date(guard.plannedStartTime);
if (isValid(parsedStart)) {
startTime = parsedStart.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
if (guard.plannedEndTime) {
const parsedEnd = new Date(guard.plannedEndTime);
if (isValid(parsedEnd)) {
endTime = parsedEnd.toLocaleTimeString("it-IT", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Europe/Rome"
});
}
}
return (
<div
key={`${guard.guardId}-${index}`}
className="border rounded-lg p-3 space-y-2"
data-testid={`guard-assignment-${index}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold text-sm">{guard.guardName}</span>
</div>
<div className="text-xs text-muted-foreground">
Matricola: {guard.badgeNumber}
</div>
</div>
</div>
<div className="flex items-center gap-1 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{startTime} - {endTime}
</span>
</div>
{/* Dotazioni */}
<div className="flex gap-2 flex-wrap">
{guard.armed && (
<Badge variant="outline" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{guard.vehicleId && (
<Badge variant="outline" className="text-xs">
<Car className="h-3 w-3 mr-1" />
{guard.vehiclePlate || "Automezzo"}
</Badge>
)}
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Site, InsertSite } from "@shared/schema"; import { Site, InsertSite, Customer, ServiceType } from "@shared/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@ -11,38 +11,56 @@ import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertSiteSchema } from "@shared/schema"; import { insertSiteSchema } from "@shared/schema";
import { Plus, MapPin, Shield, Users, Pencil } from "lucide-react"; import { Plus, MapPin, Shield, Users, Pencil, Building2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const shiftTypeLabels: Record<string, string> = { const locationLabels: Record<string, string> = {
fixed_post: "Presidio Fisso", roccapiemonte: "Roccapiemonte",
patrol: "Pattugliamento", milano: "Milano",
night_inspection: "Ispettorato Notturno", roma: "Roma",
quick_response: "Pronto Intervento",
}; };
export default function Sites() { export default function Sites() {
const { toast } = useToast(); const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSite, setEditingSite] = useState<Site | null>(null); const [editingSite, setEditingSite] = useState<Site | null>(null);
const [isGeocoding, setIsGeocoding] = useState(false);
const [isGeocodingEdit, setIsGeocodingEdit] = useState(false);
const { data: sites, isLoading } = useQuery<Site[]>({ const { data: sites, isLoading } = useQuery<Site[]>({
queryKey: ["/api/sites"], queryKey: ["/api/sites"],
}); });
const { data: customers } = useQuery<Customer[]>({
queryKey: ["/api/customers"],
});
const { data: serviceTypes } = useQuery<ServiceType[]>({
queryKey: ["/api/service-types"],
});
const form = useForm<InsertSite>({ const form = useForm<InsertSite>({
resolver: zodResolver(insertSiteSchema), resolver: zodResolver(insertSiteSchema),
defaultValues: { defaultValues: {
name: "", name: "",
address: "", address: "",
shiftType: "fixed_post", customerId: undefined,
location: "roccapiemonte",
serviceTypeId: undefined,
minGuards: 1, minGuards: 1,
requiresArmed: false, requiresArmed: false,
requiresDriverLicense: false, requiresDriverLicense: false,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true, isActive: true,
}, },
}); });
@ -52,10 +70,19 @@ export default function Sites() {
defaultValues: { defaultValues: {
name: "", name: "",
address: "", address: "",
shiftType: "fixed_post", customerId: undefined,
location: "roccapiemonte",
serviceTypeId: undefined,
minGuards: 1, minGuards: 1,
requiresArmed: false, requiresArmed: false,
requiresDriverLicense: false, requiresDriverLicense: false,
contractReference: "",
contractStartDate: undefined,
contractEndDate: undefined,
serviceStartTime: "",
serviceEndTime: "",
latitude: undefined,
longitude: undefined,
isActive: true, isActive: true,
}, },
}); });
@ -104,6 +131,82 @@ export default function Sites() {
}, },
}); });
const handleGeocode = async () => {
const address = form.getValues("address");
if (!address) {
toast({
title: "Indirizzo mancante",
description: "Inserisci un indirizzo prima di cercare le coordinate",
variant: "destructive",
});
return;
}
setIsGeocoding(true);
try {
const response = await apiRequest(
"POST",
"/api/geocode",
{ address }
);
const result = await response.json();
form.setValue("latitude", result.latitude);
form.setValue("longitude", result.longitude);
toast({
title: "Coordinate trovate",
description: `Indirizzo: ${result.displayName}`,
});
} catch (error: any) {
toast({
title: "Errore geocodifica",
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
variant: "destructive",
});
} finally {
setIsGeocoding(false);
}
};
const handleGeocodeEdit = async () => {
const address = editForm.getValues("address");
if (!address) {
toast({
title: "Indirizzo mancante",
description: "Inserisci un indirizzo prima di cercare le coordinate",
variant: "destructive",
});
return;
}
setIsGeocodingEdit(true);
try {
const response = await apiRequest(
"POST",
"/api/geocode",
{ address }
);
const result = await response.json();
editForm.setValue("latitude", result.latitude);
editForm.setValue("longitude", result.longitude);
toast({
title: "Coordinate trovate",
description: `Indirizzo: ${result.displayName}`,
});
} catch (error: any) {
toast({
title: "Errore geocodifica",
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
variant: "destructive",
});
} finally {
setIsGeocodingEdit(false);
}
};
const onSubmit = (data: InsertSite) => { const onSubmit = (data: InsertSite) => {
createMutation.mutate(data); createMutation.mutate(data);
}; };
@ -118,15 +221,50 @@ export default function Sites() {
setEditingSite(site); setEditingSite(site);
editForm.reset({ editForm.reset({
name: site.name, name: site.name,
address: site.address, address: site.address || "",
shiftType: site.shiftType, latitude: site.latitude || "",
longitude: site.longitude || "",
customerId: site.customerId ?? undefined,
location: site.location,
serviceTypeId: site.serviceTypeId ?? undefined,
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed, requiresArmed: site.requiresArmed,
requiresDriverLicense: site.requiresDriverLicense, requiresDriverLicense: site.requiresDriverLicense,
contractReference: site.contractReference || "",
contractStartDate: site.contractStartDate || undefined,
contractEndDate: site.contractEndDate || undefined,
serviceStartTime: site.serviceStartTime || "",
serviceEndTime: site.serviceEndTime || "",
isActive: site.isActive, isActive: site.isActive,
}); });
}; };
// Funzione per determinare lo stato del contratto
const getContractStatus = (site: Site): "active" | "expiring" | "expired" | "none" => {
if (!site.contractStartDate || !site.contractEndDate) return "none";
const today = new Date();
const startDate = new Date(site.contractStartDate);
const endDate = new Date(site.contractEndDate);
if (today < startDate) return "none"; // Contratto non ancora iniziato
if (today > endDate) return "expired";
// Calcola i giorni rimanenti
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysLeft <= 30) return "expiring"; // In scadenza se mancano 30 giorni o meno
return "active";
};
const contractStatusLabels = {
active: { label: "Contratto Attivo", variant: "default" as const },
expiring: { label: "In Scadenza", variant: "outline" as const },
expired: { label: "Scaduto", variant: "destructive" as const },
none: { label: "Nessun Contratto", variant: "secondary" as const },
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -143,7 +281,7 @@ export default function Sites() {
Aggiungi Sito Aggiungi Sito
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuovo Sito</DialogTitle> <DialogTitle>Nuovo Sito</DialogTitle>
<DialogDescription> <DialogDescription>
@ -180,23 +318,193 @@ export default function Sites() {
)} )}
/> />
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
<div className="flex items-center justify-between">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
Coordinate GPS (per mappa)
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeocode}
disabled={isGeocoding || !form.watch("address")}
data-testid="button-geocode"
>
{isGeocoding ? "Ricerca in corso..." : "📍 Trova Coordinate"}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="shiftType" name="latitude"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Tipologia Servizio</FormLabel> <FormLabel>Latitudine</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-shift-type"> <Input
placeholder="41.9028"
{...field}
value={field.value || ""}
data-testid="input-latitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitudine</FormLabel>
<FormControl>
<Input
placeholder="12.4964"
{...field}
value={field.value || ""}
data-testid="input-longitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-xs text-muted-foreground">
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
</p>
</div>
<FormField
control={form.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Cliente (opzionale)</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-customer">
<SelectValue placeholder="Nessun cliente" />
</SelectTrigger>
</FormControl>
<SelectContent>
{customers?.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={form.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="serviceTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-service-type">
<SelectValue placeholder="Seleziona tipo servizio" /> <SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem> {serviceTypes?.filter(st => st.isActive).map((serviceType) => (
<SelectItem value="patrol">Pattugliamento</SelectItem> <SelectItem key={serviceType.id} value={serviceType.id}>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem> {serviceType.label}
<SelectItem value="quick_response">Pronto Intervento</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -253,6 +561,49 @@ export default function Sites() {
/> />
</div> </div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button <Button
type="button" type="button"
@ -280,7 +631,7 @@ export default function Sites() {
{/* Edit Site Dialog */} {/* Edit Site Dialog */}
<Dialog open={!!editingSite} onOpenChange={(open) => !open && setEditingSite(null)}> <Dialog open={!!editingSite} onOpenChange={(open) => !open && setEditingSite(null)}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Modifica Sito</DialogTitle> <DialogTitle>Modifica Sito</DialogTitle>
<DialogDescription> <DialogDescription>
@ -317,23 +668,193 @@ export default function Sites() {
)} )}
/> />
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
<div className="flex items-center justify-between">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
Coordinate GPS (per mappa)
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGeocodeEdit}
disabled={isGeocodingEdit || !editForm.watch("address")}
data-testid="button-geocode-edit"
>
{isGeocodingEdit ? "Ricerca in corso..." : "📍 Trova Coordinate"}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField <FormField
control={editForm.control} control={editForm.control}
name="shiftType" name="latitude"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Tipologia Servizio</FormLabel> <FormLabel>Latitudine</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-edit-shift-type"> <Input
placeholder="41.9028"
{...field}
value={field.value || ""}
data-testid="input-edit-latitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitudine</FormLabel>
<FormControl>
<Input
placeholder="12.4964"
{...field}
value={field.value || ""}
data-testid="input-edit-longitude"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-xs text-muted-foreground">
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
</p>
</div>
<FormField
control={editForm.control}
name="customerId"
render={({ field }) => (
<FormItem>
<FormLabel>Cliente (opzionale)</FormLabel>
<Select onValueChange={(value) => field.onChange(value || undefined)} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-edit-customer">
<SelectValue placeholder="Nessun cliente" />
</SelectTrigger>
</FormControl>
<SelectContent>
{customers?.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Sede Gestionale</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "roccapiemonte"}>
<FormControl>
<SelectTrigger data-testid="select-edit-location">
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Dati Contrattuali</p>
<FormField
control={editForm.control}
name="contractReference"
render={({ field }) => (
<FormItem>
<FormLabel>Riferimento Contratto</FormLabel>
<FormControl>
<Input placeholder="CT-2025-001" {...field} value={field.value || ""} data-testid="input-edit-contract-reference" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="contractStartDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Inizio Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-start-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="contractEndDate"
render={({ field }) => (
<FormItem>
<FormLabel>Data Fine Contratto</FormLabel>
<FormControl>
<Input
type="date"
{...field}
value={field.value || ""}
data-testid="input-edit-contract-end-date"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormField
control={editForm.control}
name="serviceTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl>
<SelectTrigger data-testid="select-edit-service-type">
<SelectValue placeholder="Seleziona tipo servizio" /> <SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="fixed_post">Presidio Fisso</SelectItem> {serviceTypes?.filter(st => st.isActive).map((serviceType) => (
<SelectItem value="patrol">Pattugliamento</SelectItem> <SelectItem key={serviceType.id} value={serviceType.id}>
<SelectItem value="night_inspection">Ispettorato Notturno</SelectItem> {serviceType.label}
<SelectItem value="quick_response">Pronto Intervento</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -403,6 +924,49 @@ export default function Sites() {
/> />
</div> </div>
<div className="border-t pt-4 space-y-4">
<p className="text-sm font-medium">Orari Servizio</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="serviceStartTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Inizio</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-start-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="serviceEndTime"
render={({ field }) => (
<FormItem>
<FormLabel>Orario Fine</FormLabel>
<FormControl>
<Input
type="time"
{...field}
value={field.value || ""}
data-testid="input-edit-service-end-time"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex gap-3 pt-4"> <div className="flex gap-3 pt-4">
<Button <Button
type="button" type="button"
@ -441,9 +1005,15 @@ export default function Sites() {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">{site.name}</CardTitle> <CardTitle className="text-lg truncate">{site.name}</CardTitle>
<CardDescription className="text-xs mt-1"> <CardDescription className="text-xs mt-1 space-y-0.5">
<div>
<MapPin className="h-3 w-3 inline mr-1" /> <MapPin className="h-3 w-3 inline mr-1" />
{site.address} {site.address}
</div>
<div>
<Building2 className="h-3 w-3 inline mr-1" />
Sede: {locationLabels[site.location]}
</div>
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -462,12 +1032,33 @@ export default function Sites() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div> <div className="flex flex-wrap gap-2">
<Badge variant="outline"> {site.serviceTypeId && serviceTypes && (() => {
{shiftTypeLabels[site.shiftType]} const serviceType = serviceTypes.find(st => st.id === site.serviceTypeId);
return serviceType ? (
<Badge variant="outline" data-testid={`badge-service-type-${site.id}`}>
{serviceType.label}
</Badge> </Badge>
) : null;
})()}
{(() => {
const status = getContractStatus(site);
const statusInfo = contractStatusLabels[status];
return (
<Badge variant={statusInfo.variant} data-testid={`badge-contract-status-${site.id}`}>
{statusInfo.label}
</Badge>
);
})()}
</div> </div>
{site.contractReference && (
<div className="text-xs text-muted-foreground">
Contratto: {site.contractReference}
{site.contractEndDate && ` • Scade: ${new Date(site.contractEndDate).toLocaleDateString('it-IT')}`}
</div>
)}
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />

View File

@ -0,0 +1,448 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users, Clock, MapPin, Navigation, ExternalLink } from "lucide-react";
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
import { Link } from "wouter";
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
interface GuardScheduleData {
guard: {
id: string;
firstName: string;
lastName: string;
badgeNumber: string;
};
fixedShifts: Array<{
assignmentId: string;
shiftId: string;
plannedStartTime: Date;
plannedEndTime: Date;
siteName: string;
siteId: string;
}>;
mobileShifts: Array<{
routeId: string;
shiftDate: string;
startTime: string;
endTime: string;
}>;
absences: Array<{
id: string;
type: AbsenceType;
startDate: string;
endDate: string;
}>;
}
interface WeeklyScheduleResponse {
weekStart: string;
weekEnd: string;
location: string;
guards: GuardScheduleData[];
}
const ABSENCE_LABELS: Record<AbsenceType, string> = {
sick_leave: "Malattia",
vacation: "Ferie",
personal_leave: "Permesso",
injury: "Infortunio",
};
type DialogData = {
type: "fixed" | "mobile";
guardName: string;
date: string;
data: any;
} | null;
export default function WeeklyGuards() {
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
);
const [dialogData, setDialogData] = useState<DialogData>(null);
const { data: scheduleData, isLoading, error } = useQuery<WeeklyScheduleResponse>({
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
queryFn: async () => {
const startDate = format(currentWeekStart, "yyyy-MM-dd");
const response = await fetch(
`/api/weekly-guards-schedule?location=${selectedLocation}&startDate=${startDate}`
);
if (!response.ok) {
throw new Error("Failed to fetch weekly schedule");
}
return response.json();
},
enabled: !!selectedLocation,
});
// Helper per ottenere i giorni della settimana
const getWeekDays = () => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(addDays(currentWeekStart, i));
}
return days;
};
const weekDays = getWeekDays();
// Helper per trovare l'attività di una guardia in un giorno specifico
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
// Controlla assenze
const absence = guardData.absences.find(abs => {
const startDate = abs.startDate;
const endDate = abs.endDate;
return dateStr >= startDate && dateStr <= endDate;
});
if (absence) {
return {
type: "absence" as const,
label: ABSENCE_LABELS[absence.type],
data: absence,
};
}
// Controlla turni fissi
const fixedShift = guardData.fixedShifts.find(shift => {
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
return shiftDate === dateStr;
});
if (fixedShift) {
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
return {
type: "fixed" as const,
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
data: fixedShift,
};
}
// Controlla turni mobili
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
if (mobileShift) {
return {
type: "mobile" as const,
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
data: mobileShift,
};
}
return null;
};
const handlePreviousWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, -1));
};
const handleNextWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, 1));
};
const handleCellClick = (guardData: GuardScheduleData, activity: ReturnType<typeof getDayActivity>, date: Date) => {
if (!activity || activity.type === "absence") return;
const guardName = `${guardData.guard.lastName} ${guardData.guard.firstName}`;
const dateStr = format(date, "EEEE dd MMMM yyyy", { locale: it });
setDialogData({
type: activity.type,
guardName,
date: dateStr,
data: activity.data,
});
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="h-8 w-8 text-primary" />
Guardie Settimanale
</h1>
<p className="text-muted-foreground mt-1">
Vista riepilogativa delle assegnazioni settimanali per sede
</p>
</div>
</div>
{/* Filtri */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Filtri Visualizzazione
</CardTitle>
<CardDescription>
Seleziona sede e settimana per visualizzare le assegnazioni
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select
value={selectedLocation}
onValueChange={setSelectedLocation}
data-testid="select-location"
>
<SelectTrigger>
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePreviousWeek}
data-testid="button-previous-week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
<span className="font-medium">
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
</span>
</div>
<Button
variant="outline"
size="icon"
onClick={handleNextWeek}
data-testid="button-next-week"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Griglia Settimanale */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">Caricamento...</p>
</CardContent>
</Card>
) : error ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-destructive">
Errore nel caricamento della pianificazione. Riprova più tardi.
</p>
</CardContent>
</Card>
) : scheduleData && scheduleData.guards.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
Guardia
</th>
{weekDays.map((day, index) => (
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
<div>{format(day, "EEE", { locale: it })}</div>
<div className="text-xs text-muted-foreground font-normal">
{format(day, "dd/MM")}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{scheduleData.guards.map((guardData) => (
<tr
key={guardData.guard.id}
className="border-b hover:bg-muted/30"
data-testid={`row-guard-${guardData.guard.id}`}
>
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
<div className="flex flex-col">
<span className="text-sm">
{guardData.guard.lastName} {guardData.guard.firstName}
</span>
<span className="text-xs text-muted-foreground">
#{guardData.guard.badgeNumber}
</span>
</div>
</td>
{weekDays.map((day, dayIndex) => {
const activity = getDayActivity(guardData, day);
return (
<td
key={dayIndex}
className="p-2 text-center align-middle"
>
{activity ? (
activity.type === "absence" ? (
<div
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
onClick={() => handleCellClick(guardData, activity, day)}
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</Button>
)
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">
Nessuna guardia trovata per la sede selezionata
</p>
</CardContent>
</Card>
)}
{/* Dialog Dettaglio Turno */}
<Dialog open={!!dialogData} onOpenChange={() => setDialogData(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{dialogData?.type === "fixed" ? (
<>
<MapPin className="h-5 w-5" />
Turno Fisso - {dialogData?.guardName}
</>
) : (
<>
<Navigation className="h-5 w-5" />
Turno Mobile - {dialogData?.guardName}
</>
)}
</DialogTitle>
<DialogDescription>
{dialogData?.date}
</DialogDescription>
</DialogHeader>
{dialogData && (
<div className="space-y-4">
{dialogData.type === "fixed" ? (
// Dettagli turno fisso
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Sito</span>
<span className="text-sm font-medium">{dialogData.data.siteName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{format(new Date(dialogData.data.plannedStartTime), "HH:mm")} - {format(new Date(dialogData.data.plannedEndTime), "HH:mm")}
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Durata</span>
<span className="text-sm font-bold">
{Math.round((new Date(dialogData.data.plannedEndTime).getTime() - new Date(dialogData.data.plannedStartTime).getTime()) / (1000 * 60 * 60))}h
</span>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per modificare questo turno, vai alla pagina <Link href="/general-planning" className="text-primary font-medium hover:underline">Planning Fissi</Link>
</p>
</div>
</div>
) : (
// Dettagli turno mobile
<div className="space-y-3">
<div className="bg-muted/30 p-3 rounded-md space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tipo</span>
<Badge variant="outline">Pattuglia</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Orario</span>
<div className="flex items-center gap-1 text-sm font-medium">
<Clock className="h-3 w-3" />
{dialogData.data.startTime} - {dialogData.data.endTime}
</div>
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-md">
<p className="text-sm text-muted-foreground">
Per visualizzare il percorso completo e modificare il turno, vai alla pagina <Link href="/planning-mobile" className="text-primary font-medium hover:underline">Planning Mobile</Link>
</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogData(null)}>
Chiudi
</Button>
{dialogData?.type === "fixed" ? (
<Link href="/general-planning">
<Button data-testid="button-goto-planning-fissi">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Fissi
</Button>
</Link>
) : (
<Link href="/planning-mobile">
<Button data-testid="button-goto-planning-mobile">
<ExternalLink className="h-4 w-4 mr-2" />
Vai a Planning Mobile
</Button>
</Link>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

BIN
filled_assignment_form.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
final_reopen_edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
guard_selected_by_eval.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
guards-nav.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
no-guard-link.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
no-login-button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

105
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@ -41,6 +44,7 @@
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12", "@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -56,6 +60,7 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
@ -69,6 +74,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
@ -414,6 +420,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@ -2790,6 +2849,17 @@
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
}, },
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@replit/vite-plugin-cartographer": { "node_modules/@replit/vite-plugin-cartographer": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz",
@ -3571,6 +3641,12 @@
"@types/express": "*" "@types/express": "*"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -3578,6 +3654,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/memoizee": { "node_modules/@types/memoizee": {
"version": "0.4.12", "version": "0.4.12",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz", "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
@ -5549,6 +5634,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.29.2", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
@ -6820,6 +6911,20 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -11,6 +11,9 @@
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"@neondatabase/serverless": "^0.10.4", "@neondatabase/serverless": "^0.10.4",
@ -43,6 +46,7 @@
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12", "@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -58,6 +62,7 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
@ -71,6 +76,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",

BIN
planning_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -1,7 +1,7 @@
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza # VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
## Overview ## Overview
VigilanzaTurni is a professional 24/7 shift management system designed for security companies. It offers multi-role authentication (Admin, Coordinator, Guard, Client), comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The system supports multi-location operations (Roccapiemonte, Milano, Roma) managing 250+ security personnel across different branches. The project aims to streamline operations and enhance efficiency for security institutes. VigilanzaTurni is a professional 24/7 shift management system for security companies, designed to streamline operations and enhance efficiency. It supports multi-role authentication (Admin, Coordinator, Guard, Client) and multi-location operations, managing over 250 security personnel across different branches. Key capabilities include comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The project aims to provide a robust, scalable solution for security companies, improving operational control and resource allocation.
## User Preferences ## User Preferences
- Interfaccia in italiano - Interfaccia in italiano
@ -19,6 +19,7 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
- **Autenticazione**: Replit Auth (OIDC) - **Autenticazione**: Replit Auth (OIDC)
- **State Management**: TanStack Query v5 - **State Management**: TanStack Query v5
- **Routing**: Wouter - **Routing**: Wouter
- **Maps**: Leaflet + react-leaflet + OpenStreetMap tiles
### Design System ### Design System
- **Font Principale**: Inter (sans-serif) - **Font Principale**: Inter (sans-serif)
@ -28,27 +29,50 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
- **Componenti**: Shadcn UI with an operational design. - **Componenti**: Shadcn UI with an operational design.
### Database Schema ### Database Schema
The database includes core tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, and `notifications`. Advanced scheduling and constraints are managed via `guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, and `absence_affected_shifts`. All tables include appropriate foreign keys and unique constraints to maintain data integrity. The database supports managing users, guards, certifications, sites, shifts, shift assignments, notifications, customers, and service types. It also includes tables for advanced scheduling constraints such as guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`.
### API Endpoints ### Core Features
Comprehensive RESTful API endpoints are provided for Authentication, Users, Guards, Sites, Shifts, and Notifications, supporting full CRUD operations with role-based access control. - **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering resources by selected branch.
- **Service Type Classification**: Classifies services as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules.
### Frontend Routes - **Planning Fissi**: Weekly planning grid for fixed posts, enabling shift creation with guard availability checks. Includes weekly shift duplication feature with confirmation dialog and automatic navigation.
Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/notifications`, and `/users`, with access controlled by user roles. - **Planning Mobile**: Guard-centric interface for mobile services, displaying guard availability and hours, with an interactive Leaflet map showing sites. Features include:
- **Smart Site Assignment Indicators**: Sites already in patrol routes display "Assegnato a [Guard Name]" button with scroll-to functionality; unassigned sites show "Non assegnato" text
- **Drag-and-Drop Reordering**: Interactive drag-and-drop using @dnd-kit library for patrol route stops with visual feedback and automatic sequenceOrder persistence
- **Route Optimization**: OSRM API integration with TSP (Traveling Salesman Problem) nearest neighbor algorithm; displays total distance (km) and estimated travel time in dedicated dialog
- **Patrol Sequence List View**: Daily view of planned patrol routes with stops visualization
- **Custom Shift Timing**: Configurable start time and duration for each patrol route (replaces hardcoded 08:00-20:00)
- **Shift Overlap Validation**: POST /api/patrol-routes/check-overlaps endpoint verifies:
- No conflicts with existing fixed post shifts (shift_assignments)
- No conflicts with other mobile patrol routes
- Weekly hours compliance with contract parameters (maxHoursPerWeek + maxOvertimePerWeek)
- **Force-Save Dialog**: Interactive conflict resolution when saving patrol routes with overlaps or contractual limit violations; shows detailed conflict information and allows coordinator override
- **Multi-Day Duplication**: Duplication dialog supports "numero giorni consecutivi" field to create patrol sequences across N consecutive days; includes overlap validation (conservative approach: blocks entire operation if any day has conflicts)
- **Customer Management**: Full CRUD operations for customer details and customer-centric reporting with CSV export.
- **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and badge numbers.
- **Gestione Siti/Commesse**: Sites are associated with service types, including schedule, contract management, and location assignment. Automatic geocoding is supported.
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. Includes patrol route persistence and exclusivity constraints between fixed and mobile shifts.
- **Guard Planning Views**: Dedicated views for guards to see their fixed post shifts and mobile patrol routes.
- **Site Planning View**: Coordinators can view all guards assigned to a specific site over a week.
- **Shift Duplication Features**:
- **Weekly Copy (Planning Fissi)**: POST /api/shift-assignments/copy-week endpoint duplicates all shifts and assignments from selected week to next week (+7 days) with atomic transaction. Frontend includes confirmation dialog with week details and success feedback.
- **Patrol Sequence Duplication (Planning Mobili)**: POST /api/patrol-routes/duplicate endpoint with dual behavior: UPDATE when target date = source date (modifies guard), CREATE when different date (duplicates route with all stops). Frontend shows daily sequence list with duplication dialog (date picker defaulting to next day, guard selector pre-filled but changeable).
- **Guardie Settimanale**: Compact weekly schedule view showing all guards' assignments across the week in a grid format. Features include:
- **Weekly Grid View**: Guard names in first column, 7 daily columns (Mon-Sun) with compact cell display
- **Multi-Source Aggregation**: GET /api/weekly-guards-schedule endpoint aggregates fixed shifts, patrol routes, and absences by location and week
- **Compact Cell Format**: Fixed posts show "Site Name HH:mm-HH:mm", mobile patrols show "Pattuglia HH:mm-HH:mm", absences show status (Ferie/Malattia/Permesso/Riposo)
- **Read-Only Dialogs**: Clicking cells opens appropriate dialog (fixed shift details or mobile patrol info) with navigation links to Planning Fissi/Mobile for edits
- **Location and Week Filters**: Dropdown for branch selection, week navigation with prev/next buttons displaying "Settimana dal DD MMM al DD MMM YYYY"
### User Roles ### User Roles
- **Admin**: Full access to all functionalities, managing guards, sites, shifts, and reports. - **Admin**: Full access.
- **Coordinator**: Shift planning, guard assignment, operational site management, and reporting. - **Coordinator**: Shift planning, guard assignment, operational site management, reporting.
- **Guard**: View assigned shifts, future time-punching, notifications, and personal profile. - **Guard**: View assigned shifts, time-punching, notifications, personal profile.
- **Client**: View assigned sites, service reporting, and KPIs. - **Client**: View assigned sites, service reporting, KPIs.
### Key Features ### Critical Date/Timezone Handling
- **Dashboard Operativa**: Live KPIs (active shifts, total guards, active sites, expiring certifications) and real-time shift status. The system handles timezone conversions for shift times, converting Italy local time from the frontend to UTC for database storage, and back to Italy local time for display, accounting for DST.
- **Gestione Guardie**: Complete profiles with skill matrix (armed, fire safety, first aid, driver's license), certification management with automatic expiry, and unique badge numbers.
- **Gestione Siti/Commesse**: Service types (fixed post, patrol, night inspection, quick response) and minimum requirements (guard count, armed, driver's license).
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses (planned, active, completed, cancelled).
- **Reportistica**: Total hours worked, monthly hours per guard, shift statistics, and data export capabilities.
- **Advanced Planning**: Management of guard constraints (preferences, max hours, rest days), site preferences (preferred/blacklisted guards), contract parameters, training courses, holidays, and absences with substitution system.
## External Dependencies ## External Dependencies
- **Replit Auth**: For OpenID Connect (OIDC) based authentication. - **Replit Auth**: For OpenID Connect (OIDC) based authentication.
@ -59,7 +83,7 @@ Key frontend routes include `/`, `/guards`, `/sites`, `/shifts`, `/reports`, `/n
- **TanStack Query**: For data fetching and state management. - **TanStack Query**: For data fetching and state management.
- **Wouter**: For client-side routing. - **Wouter**: For client-side routing.
- **date-fns**: For date manipulation and formatting. - **date-fns**: For date manipulation and formatting.
- **PM2**: Production process manager for Node.js applications. - **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles.
- **Nginx**: As a reverse proxy for the production environment. - **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion.
- **Let's Encrypt**: For SSL/TLS certificates. - **OSRM (Open Source Routing Machine)**: Public API (router.project-osrm.org) for distance matrix calculation and route optimization in Planning Mobile. No authentication required.
- **GitLab CI/CD**: For continuous integration and deployment. - **@dnd-kit**: Drag-and-drop library (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities) for interactive patrol route reordering.

View File

@ -138,13 +138,38 @@ export async function setupLocalAuth(app: Express) {
}); });
// Route login locale POST // Route login locale POST
app.post("/api/local-login", passport.authenticate("local"), (req, res) => { app.post("/api/local-login", (req, res, next) => {
res.json({ passport.authenticate("local", (err: any, user: any, info: any) => {
if (err) {
return res.status(500).json({
success: false,
message: "Errore durante il login"
});
}
if (!user) {
return res.status(401).json({
success: false,
message: info?.message || "Email o password non corretti"
});
}
req.login(user, (loginErr) => {
if (loginErr) {
return res.status(500).json({
success: false,
message: "Errore durante il login"
});
}
return res.json({
success: true, success: true,
user: req.user, user: req.user,
message: "Login effettuato con successo" message: "Login effettuato con successo"
}); });
}); });
})(req, res, next);
});
// Route logout // Route logout

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ import {
guards, guards,
certifications, certifications,
vehicles, vehicles,
customers,
sites, sites,
shifts, shifts,
shiftAssignments, shiftAssignments,
@ -26,6 +27,8 @@ import {
type InsertCertification, type InsertCertification,
type Vehicle, type Vehicle,
type InsertVehicle, type InsertVehicle,
type Customer,
type InsertCustomer,
type Site, type Site,
type InsertSite, type InsertSite,
type Shift, type Shift,
@ -54,9 +57,11 @@ import {
type InsertServiceType, type InsertServiceType,
type CcnlSetting, type CcnlSetting,
type InsertCcnlSetting, type InsertCcnlSetting,
type GuardAvailability,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc } from "drizzle-orm"; import { eq, and, gte, lte, desc, or, sql as rawSql } from "drizzle-orm";
import { addDays, differenceInHours, parseISO, formatISO } from "date-fns";
export interface IStorage { export interface IStorage {
// User operations (Replit Auth required) // User operations (Replit Auth required)
@ -83,6 +88,13 @@ export interface IStorage {
updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>; updateServiceType(id: string, serviceType: Partial<InsertServiceType>): Promise<ServiceType | undefined>;
deleteServiceType(id: string): 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 // Site operations
getAllSites(): Promise<Site[]>; getAllSites(): Promise<Site[]>;
getSite(id: string): Promise<Site | undefined>; getSite(id: string): Promise<Site | undefined>;
@ -153,6 +165,17 @@ export interface IStorage {
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>; getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>; upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
deleteCcnlSetting(key: string): Promise<void>; deleteCcnlSetting(key: string): Promise<void>;
// General Planning operations
getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]>;
// Shift Assignment operations with time slot management
deleteShiftAssignment(id: string): Promise<void>;
} }
export class DatabaseStorage implements IStorage { export class DatabaseStorage implements IStorage {
@ -163,22 +186,31 @@ export class DatabaseStorage implements IStorage {
} }
async upsertUser(userData: UpsertUser): Promise<User> { async upsertUser(userData: UpsertUser): Promise<User> {
// Check if user already exists by email (unique constraint) // Handle conflicts on both id (primary key) and email (unique constraint)
// Check if user exists by id or email first
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, userData.email || '')) .where(
userData.id
? or(eq(users.id, userData.id), eq(users.email, userData.email || ''))
: eq(users.email, userData.email || '')
)
.limit(1); .limit(1);
if (existingUser.length > 0) { if (existingUser.length > 0) {
// Update existing user // Update existing user - NEVER change the ID (it's a primary key)
const [updated] = await db const [updated] = await db
.update(users) .update(users)
.set({ .set({
...userData, ...(userData.email && { email: userData.email }),
...(userData.firstName && { firstName: userData.firstName }),
...(userData.lastName && { lastName: userData.lastName }),
...(userData.profileImageUrl && { profileImageUrl: userData.profileImageUrl }),
...(userData.role && { role: userData.role }),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(users.email, userData.email || '')) .where(eq(users.id, existingUser[0].id))
.returning(); .returning();
return updated; return updated;
} else { } else {
@ -320,6 +352,35 @@ export class DatabaseStorage implements IStorage {
return deleted; 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 // Site operations
async getAllSites(): Promise<Site[]> { async getAllSites(): Promise<Site[]> {
return await db.select().from(sites); return await db.select().from(sites);
@ -653,6 +714,221 @@ export class DatabaseStorage implements IStorage {
async deleteCcnlSetting(key: string): Promise<void> { async deleteCcnlSetting(key: string): Promise<void> {
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key)); await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
} }
// General Planning operations with time slot conflict detection
async getGuardsAvailability(
siteId: string,
location: string,
plannedStart: Date,
plannedEnd: Date
): Promise<GuardAvailability[]> {
// Helper: Check if two time ranges overlap
const hasOverlap = (start1: Date, end1: Date, start2: Date, end2: Date): boolean => {
return start1 < end2 && end1 > start2;
};
// Helper: Calculate night hours (22:00-06:00)
const calculateNightHours = (start: Date, end: Date): number => {
let nightHours = 0;
const current = new Date(start);
while (current < end) {
const hour = current.getUTCHours();
if (hour >= 22 || hour < 6) {
nightHours += 1;
}
current.setHours(current.getHours() + 1);
}
return nightHours;
};
// Calculate week boundaries for weekly hours calculation
const weekStart = new Date(plannedStart);
weekStart.setDate(plannedStart.getDate() - plannedStart.getDay() + (plannedStart.getDay() === 0 ? -6 : 1));
weekStart.setHours(0, 0, 0, 0);
const weekEnd = addDays(weekStart, 6);
weekEnd.setHours(23, 59, 59, 999);
// Get contract parameters
let contractParams = await this.getContractParameters();
if (!contractParams) {
contractParams = await this.createContractParameters({
contractType: "CCNL_VIGILANZA_2024",
});
}
const maxOrdinaryHours = contractParams.maxHoursPerWeek || 40; // 40h
const maxOvertimeHours = contractParams.maxOvertimePerWeek || 8; // 8h
const maxTotalHours = maxOrdinaryHours + maxOvertimeHours; // 48h
const maxNightHours = contractParams.maxNightHoursPerWeek || 48; // 48h
const minDailyRest = contractParams.minDailyRestHours || 11; // 11h
// Get site to check requirements
const site = await this.getSite(siteId);
if (!site) {
return [];
}
// Get all guards from the same location
const allGuards = await db
.select()
.from(guards)
.where(eq(guards.location, location as any));
// Filter guards by site requirements
const eligibleGuards = allGuards.filter((guard: Guard) => {
if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true;
});
const requestedHours = differenceInHours(plannedEnd, plannedStart);
const requestedNightHours = calculateNightHours(plannedStart, plannedEnd);
// Analyze each guard's availability
const guardsWithAvailability: GuardAvailability[] = [];
for (const guard of eligibleGuards) {
// Get all shift assignments for this guard in the week (for weekly hours)
const weeklyAssignments = await db
.select({
id: shiftAssignments.id,
shiftId: shiftAssignments.shiftId,
plannedStartTime: shiftAssignments.plannedStartTime,
plannedEndTime: shiftAssignments.plannedEndTime,
})
.from(shiftAssignments)
.where(
and(
eq(shiftAssignments.guardId, guard.id),
gte(shiftAssignments.plannedStartTime, weekStart),
lte(shiftAssignments.plannedStartTime, weekEnd)
)
)
.orderBy(desc(shiftAssignments.plannedEndTime));
// Calculate total weekly hours and night hours assigned
let weeklyHoursAssigned = 0;
let nightHoursAssigned = 0;
let lastShiftEnd: Date | null = null;
for (const assignment of weeklyAssignments) {
const hours = differenceInHours(assignment.plannedEndTime, assignment.plannedStartTime);
weeklyHoursAssigned += hours;
nightHoursAssigned += calculateNightHours(assignment.plannedStartTime, assignment.plannedEndTime);
// Track last shift end for rest calculation
if (!lastShiftEnd || assignment.plannedEndTime > lastShiftEnd) {
lastShiftEnd = assignment.plannedEndTime;
}
}
// Calculate ordinary and overtime hours
const ordinaryHoursAssigned = Math.min(weeklyHoursAssigned, maxOrdinaryHours);
const overtimeHoursAssigned = Math.max(0, weeklyHoursAssigned - maxOrdinaryHours);
const ordinaryHoursRemaining = Math.max(0, maxOrdinaryHours - weeklyHoursAssigned);
const overtimeHoursRemaining = Math.max(0, maxOvertimeHours - overtimeHoursAssigned);
const weeklyHoursRemaining = ordinaryHoursRemaining + overtimeHoursRemaining;
// Check if shift requires overtime
const requiresOvertime = requestedHours > ordinaryHoursRemaining;
// Check for time conflicts with the requested slot
const conflicts = [];
const reasons: string[] = [];
for (const assignment of weeklyAssignments) {
if (hasOverlap(plannedStart, plannedEnd, assignment.plannedStartTime, assignment.plannedEndTime)) {
// Get site name for conflict
const [shift] = await db
.select({ siteName: sites.name })
.from(shifts)
.innerJoin(sites, eq(shifts.siteId, sites.id))
.where(eq(shifts.id, assignment.shiftId));
conflicts.push({
from: assignment.plannedStartTime,
to: assignment.plannedEndTime,
siteName: shift?.siteName || 'Sito sconosciuto',
shiftId: assignment.shiftId,
});
}
}
// Check rest violation (11h between shifts)
let hasRestViolation = false;
if (lastShiftEnd) {
const hoursSinceLastShift = differenceInHours(plannedStart, lastShiftEnd);
if (hoursSinceLastShift < minDailyRest) {
hasRestViolation = true;
reasons.push(`Riposo insufficiente (${Math.max(0, hoursSinceLastShift).toFixed(1)}h dall'ultimo turno, minimo ${minDailyRest}h)`);
}
}
// Determine availability
let isAvailable = true;
// EXCLUDE guards already assigned on same day/time
if (conflicts.length > 0) {
isAvailable = false;
reasons.push(`Già assegnata in ${conflicts.length} turno/i nello stesso orario`);
}
// Check if enough hours available (total)
if (weeklyHoursRemaining < requestedHours) {
isAvailable = false;
reasons.push(`Ore settimanali insufficienti (${Math.max(0, weeklyHoursRemaining)}h disponibili, ${requestedHours}h richieste)`);
}
// Check night hours limit
if (nightHoursAssigned + requestedNightHours > maxNightHours) {
isAvailable = false;
reasons.push(`Ore notturne esaurite (${nightHoursAssigned}h lavorate, max ${maxNightHours}h/settimana)`);
}
// Rest violation makes guard unavailable
if (hasRestViolation) {
isAvailable = false;
}
// Build guard name from new fields
const guardName = guard.firstName && guard.lastName
? `${guard.firstName} ${guard.lastName}`
: guard.badgeNumber;
guardsWithAvailability.push({
guardId: guard.id,
guardName,
badgeNumber: guard.badgeNumber,
weeklyHoursRemaining,
weeklyHoursAssigned,
weeklyHoursMax: maxTotalHours,
ordinaryHoursRemaining,
overtimeHoursRemaining,
nightHoursAssigned,
requiresOvertime,
hasRestViolation,
lastShiftEnd,
isAvailable,
conflicts,
unavailabilityReasons: reasons,
});
}
// Sort: available with ordinary hours first, then overtime, then unavailable
guardsWithAvailability.sort((a, b) => {
if (a.isAvailable && !b.isAvailable) return -1;
if (!a.isAvailable && b.isAvailable) return 1;
if (a.isAvailable && b.isAvailable) {
if (!a.requiresOvertime && b.requiresOvertime) return -1;
if (a.requiresOvertime && !b.requiresOvertime) return 1;
}
return b.weeklyHoursRemaining - a.weeklyHoursRemaining;
});
return guardsWithAvailability;
}
} }
export const storage = new DatabaseStorage(); export const storage = new DatabaseStorage();

View File

@ -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
@ -122,6 +127,11 @@ export const users = pgTable("users", {
export const guards = pgTable("guards", { export const guards = pgTable("guards", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").references(() => users.id), userId: varchar("user_id").references(() => users.id),
// Anagrafica
firstName: varchar("first_name").notNull(),
lastName: varchar("last_name").notNull(),
email: varchar("email"),
badgeNumber: varchar("badge_number").notNull().unique(), badgeNumber: varchar("badge_number").notNull().unique(),
phoneNumber: varchar("phone_number"), phoneNumber: varchar("phone_number"),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
@ -183,6 +193,38 @@ export const serviceTypes = pgTable("service_types", {
description: text("description"), // Descrizione dettagliata description: text("description"), // Descrizione dettagliata
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
fixedPostHours: integer("fixed_post_hours"), // Ore presidio fisso (es. 8, 12)
patrolPassages: integer("patrol_passages"), // Numero passaggi pattugliamento (es. 3, 5)
inspectionFrequency: integer("inspection_frequency"), // Frequenza ispezioni in minuti
responseTimeMinutes: integer("response_time_minutes"), // Tempo risposta pronto intervento
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(),
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), isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
@ -194,15 +236,25 @@ export const sites = pgTable("sites", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
name: varchar("name").notNull(), name: varchar("name").notNull(),
address: varchar("address").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 location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements // Service requirements
shiftType: shiftTypeEnum("shift_type").notNull(), serviceTypeId: varchar("service_type_id").references(() => serviceTypes.id),
shiftType: shiftTypeEnum("shift_type"), // Optional - can be derived from service type
minGuards: integer("min_guards").notNull().default(1), minGuards: integer("min_guards").notNull().default(1),
requiresArmed: boolean("requires_armed").default(false), requiresArmed: boolean("requires_armed").default(false),
requiresDriverLicense: boolean("requires_driver_license").default(false), requiresDriverLicense: boolean("requires_driver_license").default(false),
// Orari servizio (formato HH:MM, es. "08:00", "20:00")
serviceStartTime: varchar("service_start_time"), // Orario inizio servizio
serviceEndTime: varchar("service_end_time"), // Orario fine servizio
// Dati contrattuali
contractReference: varchar("contract_reference"), // Riferimento/numero contratto
contractStartDate: date("contract_start_date"), // Data inizio contratto
contractEndDate: date("contract_end_date"), // Data fine contratto
// Coordinates for geofencing (future use) // Coordinates for geofencing (future use)
latitude: varchar("latitude"), latitude: varchar("latitude"),
longitude: varchar("longitude"), longitude: varchar("longitude"),
@ -235,12 +287,60 @@ export const shiftAssignments = pgTable("shift_assignments", {
shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }), shiftId: varchar("shift_id").notNull().references(() => shifts.id, { onDelete: "cascade" }),
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }), guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
// Planned shift times (when the guard is scheduled to work)
plannedStartTime: timestamp("planned_start_time").notNull(),
plannedEndTime: timestamp("planned_end_time").notNull(),
assignedAt: timestamp("assigned_at").defaultNow(), assignedAt: timestamp("assigned_at").defaultNow(),
confirmedAt: timestamp("confirmed_at"), confirmedAt: timestamp("confirmed_at"),
// Actual check-in/out times // Actual check-in/out times (recorded when guard clocks in/out)
checkInTime: timestamp("check_in_time"), checkInTime: timestamp("check_in_time"),
checkOutTime: timestamp("check_out_time"), checkOutTime: timestamp("check_out_time"),
// Dotazioni operative per questo turno specifico
isArmedOnDuty: boolean("is_armed_on_duty").default(false), // Guardia armata per questo turno
assignedVehicleId: varchar("assigned_vehicle_id").references(() => vehicles.id, { onDelete: "set null" }), // Automezzo assegnato
});
// ============= PATROL ROUTES (TURNI PATTUGLIA) =============
export const patrolRoutes = pgTable("patrol_routes", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
guardId: varchar("guard_id").notNull().references(() => guards.id, { onDelete: "cascade" }),
// Data e orari del turno pattuglia
shiftDate: date("shift_date").notNull(), // Data del turno
startTime: varchar("start_time").notNull(), // Orario inizio (HH:MM)
endTime: varchar("end_time").notNull(), // Orario fine (HH:MM)
status: shiftStatusEnum("status").notNull().default("planned"),
location: locationEnum("location").notNull(), // Sede di riferimento
// Dotazioni
vehicleId: varchar("vehicle_id").references(() => vehicles.id, { onDelete: "set null" }),
isArmedRoute: boolean("is_armed_route").default(false), // Percorso con guardia armata
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const patrolRouteStops = pgTable("patrol_route_stops", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
patrolRouteId: varchar("patrol_route_id").notNull().references(() => patrolRoutes.id, { onDelete: "cascade" }),
siteId: varchar("site_id").notNull().references(() => sites.id, { onDelete: "cascade" }),
sequenceOrder: integer("sequence_order").notNull(), // Ordine nel percorso (1, 2, 3...)
estimatedArrivalTime: varchar("estimated_arrival_time"), // Orario stimato arrivo (HH:MM)
actualArrivalTime: timestamp("actual_arrival_time"), // Orario effettivo arrivo
// Check completamento tappa
isCompleted: boolean("is_completed").default(false),
completedAt: timestamp("completed_at"),
notes: text("notes"), // Note specifiche per questa tappa
createdAt: timestamp("created_at").defaultNow(),
}); });
// ============= CCNL SETTINGS ============= // ============= CCNL SETTINGS =============
@ -452,7 +552,6 @@ export const usersRelations = relations(users, ({ one, many }) => ({
fields: [users.id], fields: [users.id],
references: [guards.userId], references: [guards.userId],
}), }),
managedSites: many(sites),
notifications: many(notifications), notifications: many(notifications),
})); }));
@ -463,6 +562,7 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
}), }),
certifications: many(certifications), certifications: many(certifications),
shiftAssignments: many(shiftAssignments), shiftAssignments: many(shiftAssignments),
patrolRoutes: many(patrolRoutes),
constraints: one(guardConstraints), constraints: one(guardConstraints),
sitePreferences: many(sitePreferences), sitePreferences: many(sitePreferences),
trainingCourses: many(trainingCourses), trainingCourses: many(trainingCourses),
@ -484,12 +584,17 @@ export const certificationsRelations = relations(certifications, ({ one }) => ({
}), }),
})); }));
export const customersRelations = relations(customers, ({ many }) => ({
sites: many(sites),
}));
export const sitesRelations = relations(sites, ({ one, many }) => ({ export const sitesRelations = relations(sites, ({ one, many }) => ({
client: one(users, { customer: one(customers, {
fields: [sites.clientId], fields: [sites.customerId],
references: [users.id], references: [customers.id],
}), }),
shifts: many(shifts), shifts: many(shifts),
patrolRouteStops: many(patrolRouteStops),
preferences: many(sitePreferences), preferences: many(sitePreferences),
})); }));
@ -514,6 +619,33 @@ export const shiftAssignmentsRelations = relations(shiftAssignments, ({ one }) =
fields: [shiftAssignments.guardId], fields: [shiftAssignments.guardId],
references: [guards.id], references: [guards.id],
}), }),
assignedVehicle: one(vehicles, {
fields: [shiftAssignments.assignedVehicleId],
references: [vehicles.id],
}),
}));
export const patrolRoutesRelations = relations(patrolRoutes, ({ one, many }) => ({
guard: one(guards, {
fields: [patrolRoutes.guardId],
references: [guards.id],
}),
vehicle: one(vehicles, {
fields: [patrolRoutes.vehicleId],
references: [vehicles.id],
}),
stops: many(patrolRouteStops),
}));
export const patrolRouteStopsRelations = relations(patrolRouteStops, ({ one }) => ({
patrolRoute: one(patrolRoutes, {
fields: [patrolRouteStops.patrolRouteId],
references: [patrolRoutes.id],
}),
site: one(sites, {
fields: [patrolRouteStops.siteId],
references: [sites.id],
}),
})); }));
export const notificationsRelations = relations(notifications, ({ one }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({
@ -627,6 +759,13 @@ export const insertGuardSchema = createInsertSchema(guards).omit({
id: true, id: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}).extend({
firstName: z.string().min(1, "Nome obbligatorio"),
lastName: z.string().min(1, "Cognome obbligatorio"),
email: z.string().email("Email non valida").optional().or(z.literal("")),
badgeNumber: z.string().min(1, "Matricola obbligatoria"),
phoneNumber: z.string().optional().or(z.literal("")),
location: z.enum(["roccapiemonte", "milano", "roma"]),
}); });
export const insertCertificationSchema = createInsertSchema(certifications).omit({ export const insertCertificationSchema = createInsertSchema(certifications).omit({
@ -647,6 +786,12 @@ export const insertServiceTypeSchema = createInsertSchema(serviceTypes).omit({
updatedAt: true, updatedAt: true,
}); });
export const insertCustomerSchema = createInsertSchema(customers).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({ export const insertSiteSchema = createInsertSchema(sites).omit({
id: true, id: true,
createdAt: true, createdAt: true,
@ -659,6 +804,17 @@ export const insertShiftSchema = createInsertSchema(shifts).omit({
updatedAt: true, updatedAt: true,
}); });
export const insertPatrolRouteSchema = createInsertSchema(patrolRoutes).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertPatrolRouteStopSchema = createInsertSchema(patrolRouteStops).omit({
id: true,
createdAt: true,
});
// Form schema that accepts datetime strings and transforms to Date // Form schema that accepts datetime strings and transforms to Date
export const insertShiftFormSchema = z.object({ export const insertShiftFormSchema = z.object({
siteId: z.string().min(1, "Sito obbligatorio"), siteId: z.string().min(1, "Sito obbligatorio"),
@ -671,10 +827,25 @@ export const insertShiftFormSchema = z.object({
status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"), status: z.enum(["planned", "active", "completed", "cancelled"]).default("planned"),
}); });
export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments).omit({ export const insertShiftAssignmentSchema = createInsertSchema(shiftAssignments)
.omit({
id: true, id: true,
assignedAt: true, assignedAt: true,
}); })
.extend({
plannedStartTime: z.union([z.string(), z.date()], {
required_error: "Orario inizio richiesto",
invalid_type_error: "Orario inizio non valido",
}).transform((val) => new Date(val)),
plannedEndTime: z.union([z.string(), z.date()], {
required_error: "Orario fine richiesto",
invalid_type_error: "Orario fine non valido",
}).transform((val) => new Date(val)),
})
.refine((data) => data.plannedEndTime > data.plannedStartTime, {
message: "L'orario di fine deve essere successivo all'orario di inizio",
path: ["plannedEndTime"],
});
export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({ export const insertCcnlSettingSchema = createInsertSchema(ccnlSettings).omit({
id: true, id: true,
@ -746,6 +917,9 @@ export type Vehicle = typeof vehicles.$inferSelect;
export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>; export type InsertServiceType = z.infer<typeof insertServiceTypeSchema>;
export type ServiceType = typeof serviceTypes.$inferSelect; 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 InsertSite = z.infer<typeof insertSiteSchema>;
export type Site = typeof sites.$inferSelect; export type Site = typeof sites.$inferSelect;
@ -820,3 +994,46 @@ export type AbsenceWithDetails = Absence & {
shift: Shift; shift: Shift;
})[]; })[];
}; };
// ============= DTOs FOR GENERAL PLANNING =============
// DTO per conflitto orario guardia
export const guardConflictSchema = z.object({
from: z.date(),
to: z.date(),
siteName: z.string(),
shiftId: z.string(),
});
// DTO per disponibilità guardia con controllo conflitti orari
export const guardAvailabilitySchema = z.object({
guardId: z.string(),
guardName: z.string(),
badgeNumber: z.string(),
weeklyHoursRemaining: z.number(),
weeklyHoursAssigned: z.number(),
weeklyHoursMax: z.number(),
ordinaryHoursRemaining: z.number(), // Ore ordinarie disponibili (max 40h)
overtimeHoursRemaining: z.number(), // Ore straordinario disponibili (max 8h)
nightHoursAssigned: z.number(), // Ore notturne lavorate (22:00-06:00)
requiresOvertime: z.boolean(), // True se richiede straordinario
hasRestViolation: z.boolean(), // True se viola riposo obbligatorio
lastShiftEnd: z.date().nullable(), // Fine ultimo turno (per calcolo riposo)
isAvailable: z.boolean(),
conflicts: z.array(guardConflictSchema),
unavailabilityReasons: z.array(z.string()),
});
export type GuardConflict = z.infer<typeof guardConflictSchema>;
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
// DTO per creazione turno multi-giorno dal Planning Generale
export const createMultiDayShiftSchema = z.object({
siteId: z.string(),
startDate: z.string(), // YYYY-MM-DD
days: z.number().min(1).max(7),
guardId: z.string(),
shiftType: z.enum(["fixed_post", "patrol", "night_inspection", "quick_response"]).optional(),
});
export type CreateMultiDayShiftRequest = z.infer<typeof createMultiDayShiftSchema>;

BIN
step11_toggled_switches.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
step14_after_submit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
step15_post_submit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
step4_page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
step5_guards_nav.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
step8_add_guard_clicked.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -1,7 +1,289 @@
{ {
"version": "1.0.13", "version": "1.1.1",
"lastUpdate": "2025-10-17T10:17:48.446Z", "lastUpdate": "2025-11-15T10:11:44.404Z",
"changelog": [ "changelog": [
{
"version": "1.1.1",
"date": "2025-11-15",
"type": "patch",
"description": "Deployment automatico v1.1.1"
},
{
"version": "1.1.0",
"date": "2025-10-25",
"type": "minor",
"description": "Deployment automatico v1.1.0"
},
{
"version": "1.0.58",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.58"
},
{
"version": "1.0.57",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.57"
},
{
"version": "1.0.56",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.56"
},
{
"version": "1.0.55",
"date": "2025-10-25",
"type": "patch",
"description": "Deployment automatico v1.0.55"
},
{
"version": "1.0.54",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.54"
},
{
"version": "1.0.53",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.53"
},
{
"version": "1.0.52",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.52"
},
{
"version": "1.0.51",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.51"
},
{
"version": "1.0.50",
"date": "2025-10-24",
"type": "patch",
"description": "Deployment automatico v1.0.50"
},
{
"version": "1.0.49",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.49"
},
{
"version": "1.0.48",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.48"
},
{
"version": "1.0.47",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.47"
},
{
"version": "1.0.46",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.46"
},
{
"version": "1.0.45",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.45"
},
{
"version": "1.0.44",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.44"
},
{
"version": "1.0.43",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.43"
},
{
"version": "1.0.42",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.42"
},
{
"version": "1.0.41",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.41"
},
{
"version": "1.0.40",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.40"
},
{
"version": "1.0.39",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.39"
},
{
"version": "1.0.38",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.38"
},
{
"version": "1.0.37",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.37"
},
{
"version": "1.0.36",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.36"
},
{
"version": "1.0.35",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.35"
},
{
"version": "1.0.34",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.34"
},
{
"version": "1.0.33",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.33"
},
{
"version": "1.0.32",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.32"
},
{
"version": "1.0.31",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.31"
},
{
"version": "1.0.30",
"date": "2025-10-22",
"type": "patch",
"description": "Deployment automatico v1.0.30"
},
{
"version": "1.0.29",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.29"
},
{
"version": "1.0.28",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.28"
},
{
"version": "1.0.27",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.27"
},
{
"version": "1.0.26",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.26"
},
{
"version": "1.0.25",
"date": "2025-10-21",
"type": "patch",
"description": "Deployment automatico v1.0.25"
},
{
"version": "1.0.24",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.24"
},
{
"version": "1.0.23",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.23"
},
{
"version": "1.0.22",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.22"
},
{
"version": "1.0.21",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.21"
},
{
"version": "1.0.20",
"date": "2025-10-18",
"type": "patch",
"description": "Deployment automatico v1.0.20"
},
{
"version": "1.0.19",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.19"
},
{
"version": "1.0.18",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.18"
},
{
"version": "1.0.17",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.17"
},
{
"version": "1.0.16",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.16"
},
{
"version": "1.0.15",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.15"
},
{
"version": "1.0.14",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.14"
},
{ {
"version": "1.0.13", "version": "1.0.13",
"date": "2025-10-17", "date": "2025-10-17",
@ -19,72 +301,6 @@
"date": "2025-10-17", "date": "2025-10-17",
"type": "patch", "type": "patch",
"description": "Deployment automatico v1.0.11" "description": "Deployment automatico v1.0.11"
},
{
"version": "1.0.10",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.10"
},
{
"version": "1.0.9",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.9"
},
{
"version": "1.0.8",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.8"
},
{
"version": "1.0.7",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.7"
},
{
"version": "1.0.6",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.6"
},
{
"version": "1.0.5",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.5"
},
{
"version": "1.0.4",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.4"
},
{
"version": "1.0.3",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.3"
},
{
"version": "1.0.2",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.2"
},
{
"version": "1.0.1",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.1"
},
{
"version": "1.0.0",
"date": "2025-01-17",
"type": "initial",
"description": "Versione iniziale VigilanzaTurni - Sistema completo gestione turni vigilanza"
} }
] ]
} }