From 5136b1d6b582fe8f0b49b7e56e5ac67ae26f8246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:14:51 -0500 Subject: [PATCH 1/2] other modifications days ago --- .../src/components/events/EventFormWizard.jsx | 2 +- frontend-web-free/src/pages/Teams.jsx | 14 +- frontend-web-free/vendor_analysis_report.json | 249 ++ frontend-web-free/vendor_deep_analysis.json | 173 ++ frontend-web-free/vendor_workflows.json | 218 ++ frontend-web-free/workflow_table_part.txt | 60 + .../budget/BudgetUtilizationTracker.jsx | 401 ++++ .../dashboard/DashboardCustomizer.jsx | 20 +- .../src/components/events/EventFormWizard.jsx | 234 +- .../components/invoices/InvoiceDetailView.jsx | 1298 ++++++++-- .../invoices/InvoiceQuickActions.jsx | 342 +++ .../components/reports/ClientTrendsReport.jsx | 121 +- .../reports/OperationalEfficiencyReport.jsx | 117 +- .../src/components/reports/ReportExporter.jsx | 325 +++ .../reports/ReportInsightsBanner.jsx | 178 ++ .../components/reports/ReportPDFPreview.jsx | 184 ++ .../reports/ReportTemplateLibrary.jsx | 180 ++ .../components/reports/ScheduledReports.jsx | 313 +++ .../reports/StaffPerformanceReport.jsx | 104 +- .../components/reports/StaffingCostReport.jsx | 89 +- .../savings/ContractConversionMap.jsx | 422 ++++ .../components/savings/ConversionModal.jsx | 690 ++++++ .../savings/DynamicSavingsDashboard.jsx | 335 +++ .../components/savings/LaborSpendAnalysis.jsx | 272 +++ .../savings/PredictiveSavingsModel.jsx | 259 ++ .../savings/SavingsOverviewCards.jsx | 359 +++ .../savings/VendorPerformanceMatrix.jsx | 299 +++ frontend-web/src/components/ui/use-toast.jsx | 16 +- .../components/vendor/ClientLoyaltyCard.jsx | 194 ++ .../vendor/ClientVendorPreferences.jsx | 386 +++ .../vendor/SmartOperationStrategies.jsx | 408 ++++ frontend-web/src/pages/AddStaff.jsx | 80 +- frontend-web/src/pages/Certification.jsx | 769 +++++- frontend-web/src/pages/CreateEvent.jsx | 8 +- frontend-web/src/pages/Dashboard.jsx | 563 +++-- frontend-web/src/pages/EmployeeDocuments.jsx | 703 ++++++ frontend-web/src/pages/InvoiceEditor.jsx | 1174 +++++++-- frontend-web/src/pages/Invoices.jsx | 626 +++-- frontend-web/src/pages/Layout.jsx | 347 ++- frontend-web/src/pages/Reports.jsx | 292 ++- frontend-web/src/pages/SavingsEngine.jsx | 326 +++ frontend-web/src/pages/StaffDirectory.jsx | 94 +- frontend-web/src/pages/Teams.jsx | 226 +- frontend-web/src/pages/VendorCompliance.jsx | 2118 +++++++---------- frontend-web/src/pages/VendorDashboard.jsx | 31 +- frontend-web/src/pages/VendorMarketplace.jsx | 10 +- frontend-web/src/pages/index.jsx | 580 +++-- .../.dart_tool/extension_discovery/README.md | 31 + .../extension_discovery/vs_code.json | 1 + mock_staff_app.md | 662 ++++++ mock_staff_app_v2.md | 859 +++++++ mock_staff_data_v3_update.md | 594 +++++ staff_app_schema_analysis.md | 286 +++ validation_staff_mock_dataconecct.md | 189 ++ ...tion_staff_mock_dataconecct_last_update.md | 333 +++ validation_staff_mock_dataconecct_v2.md | 294 +++ 56 files changed, 16364 insertions(+), 3094 deletions(-) create mode 100644 frontend-web-free/vendor_analysis_report.json create mode 100644 frontend-web-free/vendor_deep_analysis.json create mode 100644 frontend-web-free/vendor_workflows.json create mode 100644 frontend-web-free/workflow_table_part.txt create mode 100644 frontend-web/src/components/budget/BudgetUtilizationTracker.jsx create mode 100644 frontend-web/src/components/invoices/InvoiceQuickActions.jsx create mode 100644 frontend-web/src/components/reports/ReportExporter.jsx create mode 100644 frontend-web/src/components/reports/ReportInsightsBanner.jsx create mode 100644 frontend-web/src/components/reports/ReportPDFPreview.jsx create mode 100644 frontend-web/src/components/reports/ReportTemplateLibrary.jsx create mode 100644 frontend-web/src/components/reports/ScheduledReports.jsx create mode 100644 frontend-web/src/components/savings/ContractConversionMap.jsx create mode 100644 frontend-web/src/components/savings/ConversionModal.jsx create mode 100644 frontend-web/src/components/savings/DynamicSavingsDashboard.jsx create mode 100644 frontend-web/src/components/savings/LaborSpendAnalysis.jsx create mode 100644 frontend-web/src/components/savings/PredictiveSavingsModel.jsx create mode 100644 frontend-web/src/components/savings/SavingsOverviewCards.jsx create mode 100644 frontend-web/src/components/savings/VendorPerformanceMatrix.jsx create mode 100644 frontend-web/src/components/vendor/ClientLoyaltyCard.jsx create mode 100644 frontend-web/src/components/vendor/ClientVendorPreferences.jsx create mode 100644 frontend-web/src/components/vendor/SmartOperationStrategies.jsx create mode 100644 frontend-web/src/pages/EmployeeDocuments.jsx create mode 100644 frontend-web/src/pages/SavingsEngine.jsx create mode 100644 mobile-apps/.dart_tool/extension_discovery/README.md create mode 100644 mobile-apps/.dart_tool/extension_discovery/vs_code.json create mode 100644 mock_staff_app.md create mode 100644 mock_staff_app_v2.md create mode 100644 mock_staff_data_v3_update.md create mode 100644 staff_app_schema_analysis.md create mode 100644 validation_staff_mock_dataconecct.md create mode 100644 validation_staff_mock_dataconecct_last_update.md create mode 100644 validation_staff_mock_dataconecct_v2.md diff --git a/frontend-web-free/src/components/events/EventFormWizard.jsx b/frontend-web-free/src/components/events/EventFormWizard.jsx index ab93f5e6..8b66f11c 100644 --- a/frontend-web-free/src/components/events/EventFormWizard.jsx +++ b/frontend-web-free/src/components/events/EventFormWizard.jsx @@ -644,7 +644,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm - {vendors.filter(v => v.approval_status === 'approved').map((vendor) => ( + {vendors.filter(v => v.approval_status === 'APPROVED').map((vendor) => ( {vendor.legal_name || vendor.doing_business_as} {currentUserData?.preferred_vendor_id === vendor.id && ( diff --git a/frontend-web-free/src/pages/Teams.jsx b/frontend-web-free/src/pages/Teams.jsx index 3a198fae..d649a332 100644 --- a/frontend-web-free/src/pages/Teams.jsx +++ b/frontend-web-free/src/pages/Teams.jsx @@ -128,14 +128,14 @@ export default function Teams() { teamName: teamName, ownerId: user.id, // CRITICAL: Links team to THIS user only ownerName: user.fullName || user.email, - ownerRole: userRole, // Tracks which layer this team belongs to + ownerRole: userRole.toUpperCase(), // Tracks which layer this team belongs to //email: user.email, //phone: user.phone || "", //total_members: 0, //active_members: 0, //total_hubs: 0, - favoriteStaff: 0, - blockedStaff: 0, + //favoriteStaff: 0, + //blockedStaff: 0, //departments: [], // Initialize with an empty array for departments }); @@ -826,7 +826,7 @@ export default function Teams() { vendor: "Vendor Team", workforce: "Workforce Team" }; - return titles[userRole] || "Team"; + return titles[userRole] || "TEAM"; }; const getIsolatedSubtitle = () => { @@ -934,9 +934,9 @@ export default function Teams() { Isolated Team: You can only see and manage YOUR team members. - {userRole === 'vendor' && " Procurement teams are NOT visible to you."} - {userRole === 'procurement' && " Vendor teams are NOT visible to you."} - {userRole === 'operator' && " Other layer teams are NOT visible to you."} + {userRole === 'VENDOR' && " Procurement teams are NOT visible to you."} + {userRole === 'PROCUREMENT' && " Vendor teams are NOT visible to you."} + {userRole === 'OPERATOR' && " Other layer teams are NOT visible to you."}

diff --git a/frontend-web-free/vendor_analysis_report.json b/frontend-web-free/vendor_analysis_report.json new file mode 100644 index 00000000..2f67fb73 --- /dev/null +++ b/frontend-web-free/vendor_analysis_report.json @@ -0,0 +1,249 @@ +{ + "role": "VENDOR", + "role_detection": { + "summary": "La aplicación determina el rol 'VENDOR' revisando las propiedades 'user_role' o 'role' en el objeto de usuario. Este objeto se obtiene de `base44.auth.me()`, que combina datos de Firebase Auth y un perfil de usuario de la base de datos (DataConnect). Existe un mecanismo de anulación para desarrollo que utiliza localStorage.", + "fields_used": [ + "user.user_role", + "user.role", + "localStorage.getItem('krow_mock_user_role')" + ], + "evidence": [ + "src/api/krowSDK.js: La función `auth.me()` unifica el objeto de usuario y prioriza el rol mock de localStorage.", + "src/pages/Layout.jsx: `const userRole = user?.user_role || user?.role || 'admin';` se usa para determinar qué menú mostrar." + ] + }, + "menu_for_role": [ + { + "label": "Home", + "route": "/VendorDashboard", + "page_component": "VendorDashboard", + "page_file": "src/pages/VendorDashboard.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Orders", + "route": "/VendorOrders", + "page_component": "VendorOrders", + "page_file": "src/pages/VendorOrders.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Service Rates", + "route": "/VendorRates", + "page_component": "VendorRates", + "page_file": "src/pages/VendorRates.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Invoices", + "route": "/Invoices", + "page_component": "Invoices", + "page_file": "src/pages/Invoices.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Workforce", + "route": "/StaffDirectory", + "page_component": "StaffDirectory", + "page_file": "src/pages/StaffDirectory.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Onboard Staff", + "route": "/StaffOnboarding", + "page_component": "StaffOnboarding", + "page_file": "src/pages/StaffOnboarding.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Team", + "route": "/Teams", + "page_component": "Teams", + "page_file": "src/pages/Teams.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Compliance", + "route": "/VendorCompliance", + "page_component": "VendorCompliance", + "page_file": "src/pages/VendorCompliance.jsx", + "visibility_condition": "userRole === 'vendor'", + "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + } + ], + "routes_for_role": [ + { + "route": "/*", + "component": "Any page component", + "file": "src/pages/index.jsx", + "guard": "ProtectedRoute", + "role_constraints": "El `ProtectedRoute` solo valida si el usuario está autenticado, no su rol. El control de acceso por rol se realiza a nivel de UI (ocultando menús en `Layout.jsx`) y a nivel de datos (filtrando datos dentro de cada página)." + } + ], + "workflows": [ + { + "name": "Vendor - Visualizar y filtrar órdenes", + "entry": { "label": "Orders", "route": "/VendorOrders" }, + "steps": ["1. Usuario navega a la página 'Orders' desde el menú.", "2. La página `VendorOrders.jsx` se renderiza.", "3. Se llama a `base44.entities.Event.list()` para obtener todas las órdenes.", "4. Los datos se filtran en el frontend para mostrar solo las órdenes donde `e.vendor_id === user.id` o `e.vendor_name === user.company_name`.", "5. El usuario utiliza las pestañas para filtrar por estado ('upcoming', 'active', 'past').", "6. El usuario escribe en la barra de búsqueda para filtrar por nombre de evento, cliente o ubicación."], + "sdk_calls": ["base44.entities.Event.list"], + "data_requirements": ["user.id", "user.company_name"], + "known_failure_modes": ["Si una orden no tiene `vendor_id` o `vendor_name` asignado correctamente, no aparecerá en la lista del vendor."], + "evidence_files": ["src/pages/VendorOrders.jsx"] + }, + { + "name": "Vendor - Editar una orden", + "entry": { "label": "Orders", "route": "/VendorOrders" }, + "steps": ["1. En la tabla de órdenes, el usuario hace clic en el ícono 'Edit'.", "2. Navega a la página `EditEvent?id={id}`.", "3. La página `EditEvent.jsx` comprueba el rol con `isVendor = user?.user_role === 'vendor'`.", "4. El formulario de evento (`EventForm`) se renderiza, pero ciertas secciones (probablemente relacionadas con cliente y precios) están deshabilitadas para el vendor (UNKNOWN - requiere análisis de `EventForm`).", "5. El vendor actualiza campos permitidos (ej. notas internas) y guarda.", "6. Se llama a la mutación `updateEvent` con los datos actualizados."], + "sdk_calls": ["base44.entities.Event.update"], + "data_requirements": ["event.id", "user.user_role"], + "known_failure_modes": ["El formulario podría fallar si intenta guardar un campo que no tiene permitido modificar."], + "evidence_files": ["src/pages/VendorOrders.jsx", "src/pages/EditEvent.jsx"] + }, + { + "name": "Vendor - Añadir un nuevo miembro de staff", + "entry": { "label": "Workforce", "route": "/StaffDirectory" }, + "steps": ["1. Usuario navega a `StaffDirectory`.", "2. Hace clic en el botón 'Add New Staff'.", "3. Navega a la página `AddStaff.jsx`.", "4. Rellena el formulario con los detalles del nuevo empleado.", "5. Al guardar, se asume que se llama a `base44.entities.Staff.create` con un payload que DEBE incluir `{ vendor_id: user.id }` para la correcta asignación."], + "sdk_calls": ["base44.entities.Staff.create"], + "data_requirements": ["user.id", "user.company_name"], + "payload_notes": ["Es crítico que el `vendor_id` del vendor logueado se inyecte en el payload de creación del staff."], + "known_failure_modes": ["Si el `vendor_id` no se envía, el nuevo miembro de staff podría quedar 'huérfano', sin ser visible para el vendor."], + "evidence_files": ["src/pages/StaffDirectory.jsx", "src/pages/AddStaff.jsx"] + }, + { + "name": "Vendor - Asignar staff a una orden (Smart Assign)", + "entry": { "label": "Orders", "route": "/VendorOrders" }, + "steps": ["1. En la tabla de órdenes, el usuario hace clic en el ícono de `UserCheck` (Smart Assign).", "2. Se abre el modal `SmartAssignModal.jsx`.", "3. El modal muestra una lista del staff disponible y compatible del vendor.", "4. El vendor selecciona uno o más miembros del staff.", "5. Al confirmar, el modal ejecuta una lógica que culmina en una llamada a `base44.entities.Event.update` para actualizar el campo `assigned_staff` de la orden."], + "sdk_calls": ["base44.entities.Event.update"], + "data_requirements": ["event.id", "event.shifts", "staff list"], + "known_failure_modes": ["El motor de 'Smart Assign' podría fallar si los datos de `shifts` en el evento son inconsistentes o si las tasas (`VendorRate`) no se encuentran."], + "evidence_files": ["src/pages/VendorOrders.jsx", "src/components/events/SmartAssignModal.jsx"] + }, + { + "name": "Vendor - Gestionar tarifas de servicio", + "entry": { "label": "Service Rates", "route": "/VendorRates" }, + "steps": ["1. Usuario navega a `VendorRates`.", "2. La vista `VendorCompanyPricebookView` se renderiza, mostrando tarifarios empresariales (ej. Aramark) y tarifarios personalizados ('Custom Rate Cards').", "3. El vendor puede crear un nuevo tarifario personalizado, asignarle un nombre y definir tarifas por rol.", "4. El vendor puede usar la función 'AI Price Check' que llama a `base44.integrations.Core.InvokeLLM` para obtener un análisis de competitividad."], + "sdk_calls": ["base44.entities.VendorRate.list", "base44.integrations.Core.InvokeLLM"], + "data_requirements": ["user.company_name"], + "known_failure_modes": ["La comparación de tarifas podría ser imprecisa si los nombres de los roles no coinciden exactamente entre los diferentes tarifarios."], + "evidence_files": ["src/pages/VendorRates.jsx"] + }, + { + "name": "Vendor - Importar certificados de staff en bloque", + "entry": { "label": "Compliance", "route": "/VendorCompliance" }, + "steps": ["1. Usuario navega a `VendorCompliance` y hace clic en 'Bulk Import'.", "2. Se abre un diálogo donde el usuario arrastra y suelta múltiples archivos (PDF, JPG).", "3. La función `processBulkFiles` se ejecuta en un bucle por cada archivo.", "4. Cada archivo se sube con `UploadFile` y luego se analiza con `InvokeLLM` para extraer el nombre del titular, fechas, etc.", "5. El sistema intenta hacer un 'fuzzy match' del nombre extraído con la lista de staff del vendor usando `findEmployeeByName`.", "6. La UI muestra los resultados, permitiendo al vendor corregir manualmente los matches fallidos.", "7. Al confirmar, se llama a `base44.entities.Certification.create` por cada certificado verificado."], + "sdk_calls": ["base44.integrations.Core.UploadFile", "base44.integrations.Core.InvokeLLM", "base44.entities.Certification.create"], + "data_requirements": ["staff list", "user.id", "user.company_name"], + "known_failure_modes": ["El 'fuzzy match' de nombres puede fallar si hay nombres similares o si el OCR de la IA extrae mal el nombre.", "La creación puede fallar si los datos extraídos (ej. fechas) no tienen el formato correcto."], + "evidence_files": ["src/pages/VendorCompliance.jsx"] + }, + { + "name": "Vendor - Crear una factura", + "entry": { "label": "Invoices", "route": "/Invoices" }, + "steps": ["1. Usuario navega a `Invoices` y hace clic en 'Create Invoice'.", "2. Se abre `CreateInvoiceModal.jsx`.", "3. Se asume que el modal permite seleccionar una orden completada para pre-rellenar los datos.", "4. El usuario revisa los detalles y confirma.", "5. Se llama a `base44.entities.Invoice.create` para generar el nuevo registro."], + "sdk_calls": ["base44.entities.Invoice.create"], + "data_requirements": ["user.id", "user.company_name", "event list"], + "known_failure_modes": ["UNKNOWN - Depende de la implementación de `CreateInvoiceModal`."], + "evidence_files": ["src/pages/Invoices.jsx", "src/components/invoices/CreateInvoiceModal.jsx"] + }, + { + "name": "Vendor - Ver su dashboard", + "entry": { "label": "Home", "route": "/VendorDashboard" }, + "steps": ["1. Al iniciar sesión, el usuario es dirigido a `VendorDashboard.jsx`.", "2. La página carga KPIs agregados: órdenes de hoy, órdenes en progreso, órdenes urgentes (RAPID) y staff asignado hoy.", "3. Muestra una tabla con las órdenes de hoy y mañana.", "4. El usuario puede hacer clic en una orden para ir al detalle (`EventDetail`) o iniciar una asignación (`SmartAssignModal`).", "5. También puede ver un carrusel de ingresos y paneles de 'Top Clients' y 'Top Performers'."], + "sdk_calls": ["base44.auth.me", "base44.entities.Event.list", "base44.entities.Staff.list"], + "data_requirements": ["user.id", "user.company_name"], + "known_failure_modes": ["Las métricas pueden ser incorrectas si el filtrado de datos en el frontend es incompleto o no coincide con el backend."], + "evidence_files": ["src/pages/VendorDashboard.jsx"] + } + ], + "payload_contracts": [ + { + "entity": "Certification", + "operations": { + "create": { + "payload_shape": ["employee_id", "employee_name", "certification_name", "certification_type", "certificate_number", "issuer", "issue_date", "expiry_date", "document_url", "vendor_id", "vendor_name", "owner", "expert_body", "validation_status", "ai_validation_result"], + "evidence": ["src/pages/VendorCompliance.jsx > handleAddCertification, handleImportMatched"] + } + }, + "missing_required_fields": [], + "fields_sent_but_not_supported": [] + }, + { + "entity": "Staff", + "operations": { + "create": { + "payload_shape": "UNKNOWN", + "evidence": ["src/pages/AddStaff.jsx (File not read, but workflow implies its existence and usage)"] + } + }, + "missing_required_fields": ["Se asume que `vendor_id` es un campo requerido, y si el frontend no lo envía, la creación resultaría en un registro huérfano."], + "fields_sent_but_not_supported": [] + }, + { + "entity": "Event", + "operations": { + "update": { + "payload_shape": ["assigned_staff", "shifts", "requested", "status"], + "evidence": ["src/pages/VendorOrders.jsx > autoAssignMutation", "src/components/events/SmartAssignModal.jsx (assumed)"] + } + }, + "missing_required_fields": [], + "fields_sent_but_not_supported": [] + }, + { + "entity": "Invoice", + "operations": { + "create": { + "payload_shape": ["vendor_id", "business_name", "amount", "... (many others)"], + "evidence": ["src/components/invoices/CreateInvoiceModal.jsx"] + } + }, + "missing_required_fields": [], + "fields_sent_but_not_supported": [] + } + ], + "top_gaps": [ + { + "issue": "Inconsistencia en el Scoping de Datos del Vendor", + "impact": "Alto. El vendor podría no ver todos sus datos. Por ejemplo, en `Invoices.jsx` se filtra por `user.vendor_id`, pero en `VendorOrders.jsx` y `VendorStaff.jsx` se filtra por `user.id`. Si estas propiedades no están alineadas, el vendor no verá sus facturas.", + "evidence": ["src/pages/Invoices.jsx", "src/pages/VendorOrders.jsx", "src/api/krowSDK.js"] + }, + { + "issue": "El filtrado de datos depende de múltiples claves, algunas frágiles", + "impact": "Medio. El código usa `vendor_id`, `vendor_name`, y `created_by` para vincular datos al vendor. Si un vendor cambia su `company_name`, podría perder acceso a datos históricos que solo estaban vinculados por nombre.", + "evidence": ["src/pages/VendorOrders.jsx", "src/pages/StaffDirectory.jsx"] + }, + { + "issue": "Creación de Staff sin garantía de asignación de `vendor_id`", + "impact": "Alto. La UI permite a un vendor ir a `AddStaff`, pero no se ha verificado si el payload de creación inyecta de forma forzosa el `vendor_id` del vendor logueado. Si no lo hace, se pueden crear empleados 'huérfanos' no asociados a ningún vendor.", + "evidence": ["src/pages/AddStaff.jsx (existence implied)", "src/pages/StaffDirectory.jsx"] + }, + { + "issue": "Permisos de edición de eventos no claros para vendors", + "impact": "Bajo. El vendor puede acceder a la ruta `EditEvent`, pero no está claro qué campos del formulario puede editar. Esto puede causar confusión o errores si intenta modificar un campo restringido.", + "evidence": ["src/pages/EditEvent.jsx"] + }, + { + "issue": "Feature de 'Scheduler' incompleta", + "impact": "Bajo. La página de órdenes (`VendorOrders.jsx`) tiene un botón para una vista de 'scheduler', pero la funcionalidad no está implementada, llevando a una UI incompleta.", + "evidence": ["src/pages/VendorOrders.jsx"] + }, + { + "issue": "El motor de 'Smart Assignment' puede no tener suficientes datos", + "impact": "Medio. La función `autoFillShifts` en `VendorOrders` depende de `allStaff`, `vendorEvents` y `vendorRates`. Si alguna de estas listas está incompleta o es incorrecta, la asignación automática fallará o dará malos resultados.", + "evidence": ["src/pages/VendorOrders.jsx", "src/components/scheduling/SmartAssignmentEngine.js"] + } + ], + "next_verifications": [ + "Leer el código de `AddStaff.jsx` y `CreateInvoiceModal.jsx` para documentar los `payload_contracts` de creación de forma precisa.", + "Analizar el componente `EventForm.jsx` para determinar qué campos están realmente deshabilitados cuando `isVendor` es `true`.", + "Revisar el schema de DataConnect (`.gql` files, si estuvieran disponibles) para verificar si existen constraints de `foreign key` que mitiguen el riesgo de datos huérfanos (ej. `Staff.vendor_id` -> `Users.id`).", + "Aclarar con el equipo de producto si un vendor debería poder crear eventos o si solo se le asignan." + ] +} diff --git a/frontend-web-free/vendor_deep_analysis.json b/frontend-web-free/vendor_deep_analysis.json new file mode 100644 index 00000000..4ad28261 --- /dev/null +++ b/frontend-web-free/vendor_deep_analysis.json @@ -0,0 +1,173 @@ +{ + "role": "VENDOR", + "role_detection": { + "summary": "La aplicación define el rol de un usuario mediante las propiedades 'user_role' o 'role' en el objeto 'user'. Este objeto se obtiene de `krowSDK.auth.me()`, que consulta la base de datos (DataConnect) usando el UID del usuario autenticado en Firebase. Adicionalmente, existe un mecanismo para desarrolladores que permite simular un rol usando `localStorage` (`krow_mock_user_role`), el cual tiene prioridad sobre el rol de la base de datos.", + "checks": [ + { + "type": "Primary Check", + "condition": "user?.user_role || user?.role", + "files": ["src/pages/Layout.jsx", "src/pages/StaffDirectory.jsx", "src/pages/EditEvent.jsx"] + }, + { + "type": "Developer Mock", + "condition": "localStorage.getItem('krow_mock_user_role')", + "files": ["src/api/krowSDK.js"] + } + ], + "evidence": [ + "src/api/krowSDK.js: La función `auth.me()` implementa esta lógica de unificación.", + "src/components/dev/RoleSwitcher.jsx: Este componente de desarrollo utiliza `localStorage` para cambiar de rol, demostrando el mecanismo de mock." + ] + }, + "menu_for_role": [ + { + "label": "Home", "icon": "LayoutDashboard", "route": "/VendorDashboard", "page_component": "VendorDashboard", "page_file": "src/pages/VendorDashboard.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Orders", "icon": "FileText", "route": "/VendorOrders", "page_component": "VendorOrders", "page_file": "src/pages/VendorOrders.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Service Rates", "icon": "DollarSign", "route": "/VendorRates", "page_component": "VendorRates", "page_file": "src/pages/VendorRates.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Invoices", "icon": "Clipboard", "route": "/Invoices", "page_component": "Invoices", "page_file": "src/pages/Invoices.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Schedule", "icon": "Calendar", "route": "/Schedule", "page_component": "Schedule", "page_file": "src/pages/Schedule.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Staff Availability", "icon": "Users", "route": "/StaffAvailability", "page_component": "StaffAvailability", "page_file": "src/pages/StaffAvailability.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Workforce", "icon": "Users", "route": "/StaffDirectory", "page_component": "StaffDirectory", "page_file": "src/pages/StaffDirectory.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Onboard Staff", "icon": "GraduationCap", "route": "/StaffOnboarding", "page_component": "StaffOnboarding", "page_file": "src/pages/StaffOnboarding.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Team", "icon": "UserCheck", "route": "/Teams", "page_component": "Teams", "page_file": "src/pages/Teams.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Task Board", "icon": "CheckSquare", "route": "/TaskBoard", "page_component": "TaskBoard", "page_file": "src/pages/TaskBoard.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Compliance", "icon": "Shield", "route": "/VendorCompliance", "page_component": "VendorCompliance", "page_file": "src/pages/VendorCompliance.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Communications", "icon": "MessageSquare", "route": "/Messages", "page_component": "Messages", "page_file": "src/pages/Messages.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Leads", "icon": "UserCheck", "route": "/Business", "page_component": "Business", "page_file": "src/pages/Business.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Business", "icon": "Briefcase", "route": "/Business", "page_component": "Business", "page_file": "src/pages/Business.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Reports", "icon": "BarChart3", "route": "/Reports", "page_component": "Reports", "page_file": "src/pages/Reports.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Audit Trail", "icon": "Activity", "route": "/ActivityLog", "page_component": "ActivityLog", "page_file": "src/pages/ActivityLog.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + }, + { + "label": "Performance", "icon": "TrendingUp", "route": "/VendorPerformance", "page_component": "VendorPerformance", "page_file": "src/pages/VendorPerformance.jsx", + "visibility_condition": "userRole === 'vendor'", "defined_in": ["src/pages/Layout.jsx > roleNavigationMap"] + } + ], + "router_all_routes": [ + { "route": "/", "component": "Home", "file": "src/pages/Home.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/login", "component": "Login", "file": "src/pages/Login.jsx", "guard": "PublicRoute", "role_constraints": "None" }, + { "route": "/Dashboard", "component": "Dashboard", "file": "src/pages/Dashboard.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/VendorDashboard", "component": "VendorDashboard", "file": "src/pages/VendorDashboard.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/VendorOrders", "component": "VendorOrders", "file": "src/pages/VendorOrders.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/VendorRates", "component": "VendorRates", "file": "src/pages/VendorRates.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/StaffDirectory", "component": "StaffDirectory", "file": "src/pages/StaffDirectory.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/Invoices", "component": "Invoices", "file": "src/pages/Invoices.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/EventDetail", "component": "EventDetail", "file": "src/pages/EventDetail.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/EditEvent", "component": "EditEvent", "file": "src/pages/EditEvent.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/AddStaff", "component": "AddStaff", "file": "src/pages/AddStaff.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/EditStaff", "component": "EditStaff", "file": "src/pages/EditStaff.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/InvoiceDetail", "component": "InvoiceDetail", "file": "src/pages/InvoiceDetail.jsx", "guard": "ProtectedRoute", "role_constraints": "Auth-only" }, + { "route": "/*", "component": "All others", "file": "src/pages/index.jsx", "guard": "ProtectedRoute", "role_constraints": "Todas las demás rutas siguen el mismo patrón: accesibles si se está autenticado, sin guard de rol a nivel de router." } + ], + "navigation_discovered_routes": [ + { "from_component": "VendorDashboard", "from_action": "Click en icono 'View' de una orden en la tabla", "to_route": "/EventDetail?id=...", "evidence": ["src/pages/VendorDashboard.jsx > handleViewOrder"] }, + { "from_component": "VendorDashboard", "from_action": "Click en una orden 'RAPID' urgente", "to_route": "/EventDetail?id=...", "evidence": ["src/pages/VendorDashboard.jsx > handleRapidClick"] }, + { "from_component": "VendorOrders", "from_action": "Click en icono 'View' de una orden", "to_route": "/EventDetail?id=...", "evidence": ["src/pages/VendorOrders.jsx > Table onClick"] }, + { "from_component": "VendorOrders", "from_action": "Click en icono 'Edit' de una orden", "to_route": "/EditEvent?id=...", "evidence": ["src/pages/VendorOrders.jsx > Table onClick"] }, + { "from_component": "StaffDirectory", "from_action": "Click en el botón 'Add New Staff'", "to_route": "/AddStaff", "evidence": ["src/pages/StaffDirectory.jsx > PageHeader actions prop"] }, + { "from_component": "StaffDirectory", "from_action": "Click en el nombre de un empleado en la lista", "to_route": "/EditStaff?id=...", "evidence": ["src/pages/StaffDirectory.jsx > Link to={createPageUrl(...)}"] }, + { "from_component": "Invoices", "from_action": "Click en botón 'View' de una factura", "to_route": "/InvoiceDetail?id=...", "evidence": ["src/pages/Invoices.jsx > Table onClick"] } + ], + "pages_inventory": { + "pages_in_menu_for_vendor": ["/VendorDashboard", "/VendorOrders", "/VendorRates", "/Invoices", "/Schedule", "/StaffAvailability", "/StaffDirectory", "/StaffOnboarding", "/Teams", "/TaskBoard", "/VendorCompliance", "/Messages", "/Business", "/Reports", "/ActivityLog", "/VendorPerformance"], + "pages_reachable_by_vendor": ["/VendorDashboard", "/VendorOrders", "/VendorRates", "/Invoices", "/Schedule", "/StaffAvailability", "/StaffDirectory", "/StaffOnboarding", "/Teams", "/TaskBoard", "/VendorCompliance", "/Messages", "/Business", "/Reports", "/ActivityLog", "/VendorPerformance", "/EventDetail", "/EditEvent", "/AddStaff", "/EditStaff", "/InvoiceDetail"], + "pages_url_direct_accessible": ["All routes defined in src/pages/index.jsx are technically accessible via URL if the user is authenticated, including admin-focused pages like /UserManagement or /EnterpriseManagement. The content within them would likely be empty or broken due to data scoping."] + }, + "workflows": [ + { "name": "Vendor - Visualizar órdenes y navegar al detalle", "entry": {"type": "menu", "route": "/VendorOrders"}, "steps": ["Usuario navega a 'Orders' desde el menú.", "Se renderiza `VendorOrders.jsx` y se llama a `base44.entities.Event.list()`.", "Los datos se filtran en el frontend por `vendor_id === user.id`.", "El usuario ve la lista de sus órdenes.", "Hace clic en el icono 'View' de una orden.", "La app navega a `/EventDetail?id=...`."], "sdk_calls": ["base44.entities.Event.list"], "payload_exact": {}, "missing_fields": [], "extra_fields": [], "scoping": "`e.vendor_id === user?.id` OR `e.vendor_name === user?.company_name` OR `e.created_by === user?.email`" }, + { "name": "Vendor - Añadir nuevo miembro de staff", "entry": {"type": "internal", "route": "/StaffDirectory -> /AddStaff"}, "steps": ["Desde `/StaffDirectory`, el usuario hace clic en 'Add New Staff'.", "Navega a `/AddStaff`.", "Completa el formulario.", "Al guardar, se llama a `base44.entities.Staff.create`."], "sdk_calls": ["base44.entities.Staff.create"], "payload_exact": "UNKNOWN", "missing_fields": ["`vendor_id` es crítico aquí y debe ser inyectado por el frontend a partir del `user.id` del vendor logueado."], "extra_fields": [], "scoping": "El scoping se debe garantizar en la escritura (create), no solo en la lectura (list).", "evidence": ["src/pages/StaffDirectory.jsx"] }, + { "name": "Vendor - Editar un miembro de staff", "entry": {"type": "internal", "route": "/StaffDirectory -> /EditStaff"}, "steps": ["Desde `/StaffDirectory`, el usuario hace clic en el nombre de un empleado.", "Navega a `/EditStaff?id=...`.", "Modifica el formulario y guarda.", "Se llama a `base44.entities.Staff.update`."], "sdk_calls": ["base44.entities.Staff.update"], "payload_exact": "UNKNOWN", "missing_fields": [], "extra_fields": [], "scoping": "El `user.id` del staff es la clave para la actualización.", "evidence": ["src/pages/StaffDirectory.jsx"] }, + { "name": "Vendor - Crear factura para un evento", "entry": {"type": "menu", "route": "/Invoices"}, "steps": ["Usuario navega a 'Invoices'.", "Hace clic en 'Create Invoice'.", "Se abre el modal `CreateInvoiceModal`.", "El usuario selecciona un evento completado.", "El modal se pre-rellena con los datos del evento.", "Al confirmar, se llama a `base44.entities.Invoice.create`."], "sdk_calls": ["base44.entities.Invoice.create"], "payload_exact": {"vendor_id": "selectedEvent.vendor_id", "amount": "...", "business_name": "..."}, "missing_fields": [], "extra_fields": [], "scoping": "El `vendor_id` se toma del evento seleccionado.", "evidence": ["src/components/invoices/CreateInvoiceModal.jsx"] }, + { "name": "Vendor - Importar certificados de staff con IA", "entry": {"type": "menu", "route": "/VendorCompliance"}, "steps": ["Usuario navega a 'Compliance' y hace clic en 'Bulk Import'.", "Arrastra y suelta archivos PDF/JPG de certificados.", "`processBulkFiles` sube cada archivo (`UploadFile`) y lo envía a `InvokeLLM` para análisis.", "La IA extrae datos (nombre, fechas, etc.).", "El sistema intenta hacer match del nombre extraído con el staff del vendor (`findEmployeeByName`).", "El vendor revisa los matches y confirma la importación.", "Se llama a `base44.entities.Certification.create` por cada certificado validado."], "sdk_calls": ["base44.integrations.Core.UploadFile", "base44.integrations.Core.InvokeLLM", "base44.entities.Certification.create"], "payload_exact": {"employee_id": "...", "certification_name": "...", "expiry_date": "...", "vendor_id": "user.id", "ai_validation_result": "{...}"}, "missing_fields": [], "extra_fields": [], "scoping": "La creación de la certificación incluye `vendor_id: user?.id`.", "evidence": ["src/pages/VendorCompliance.jsx"] } + ], + "payload_contracts": [ + { + "entity": "Certification", + "operations": { + "create": { + "signature": "`createCertMutation.mutateAsync(certData)`", + "payload": {"employee_id": "string", "employee_name": "string", "certification_name": "string", "certification_type": "string", "certificate_number": "string", "issuer": "string", "issue_date": "date string", "expiry_date": "date string", "document_url": "string", "vendor_id": "string (user.id)", "vendor_name": "string (user.company_name)", "validation_status": "string", "ai_validation_result": "object" }, + "optional_fields": ["certificate_number", "issuer", "issue_date", "document_url"], + "required_fields": ["employee_id", "certification_name", "expiry_date", "vendor_id"] + } + }, "fields_sent_but_not_supported": "UNKNOWN", "missing_fields_required_for_scoping": [], "evidence": ["src/pages/VendorCompliance.jsx > handleImportMatched"] + }, + { + "entity": "Event", + "operations": { + "update": { + "signature": "`updateEventMutation.mutate({ id, data })`", + "payload": {"assigned_staff": "array", "shifts": "array", "requested": "number", "status": "string" }, + "optional_fields": ["assigned_staff", "shifts", "requested", "status"], + "required_fields": [] + } + }, "fields_sent_but_not_supported": "UNKNOWN", "missing_fields_required_for_scoping": [], "evidence": ["src/pages/VendorOrders.jsx > autoAssignMutation"] + }, + { + "entity": "Staff", + "operations": { "create": { "signature": "UNKNOWN", "payload": "UNKNOWN" } }, + "fields_sent_but_not_supported": "UNKNOWN", "missing_fields_required_for_scoping": ["`vendor_id` es esencial y debe ser añadido en el frontend antes de enviar a la API."], "evidence": ["src/pages/AddStaff.jsx (File not analyzed but existence is confirmed)"] + } + ], + "top_gaps": [ + { "issue": "INCONSISTENCIA CRÍTICA en Scoping de Datos", "impact": "MUY ALTO. El vendor puede no ver datos que le pertenecen. `Invoices.jsx` filtra por `user.vendor_id` mientras que `VendorOrders.jsx` y `StaffDirectory.jsx` filtran por `user.id`. Si el objeto `user` no tiene la propiedad `vendor_id` o es diferente de `id`, las facturas no aparecerán. Esto es un bug severo.", "evidence": ["src/pages/Invoices.jsx", "src/pages/VendorOrders.jsx"] }, + { "issue": "Mecanismo de Scoping Frágil y Múltiple", "impact": "ALTO. El código recurrentemente usa `vendor_id === user.id || vendor_name === user.company_name || created_by === user.email`. Depender de `vendor_name` o `created_by` es muy arriesgado. Un cambio de nombre de la empresa o del email del creador podría 'desvincular' datos, haciéndolos inaccesibles.", "evidence": ["src/pages/StaffDirectory.jsx", "src/pages/VendorDashboard.jsx"] }, + { "issue": "Riesgo de Datos Huérfanos en Creación", "impact": "ALTO. La UI permite al vendor crear entidades como `Staff`. Sin embargo, no se ha verificado que el `vendor_id` se esté inyectando de forma garantizada en el payload de creación. Si el frontend no añade `vendor_id: user.id`, ese nuevo staff podría crearse sin dueño, siendo invisible para todos.", "evidence": ["Workflow 'Vendor - Añadir nuevo miembro de staff'"] }, + { "issue": "Seguridad de Rutas Basada solo en Ocultación de UI", "impact": "MEDIO. No hay guards de rol a nivel de router. Un vendor podría acceder a `/UserManagement` o `/EnterpriseManagement` si conoce la URL. Aunque la página probablemente no funcione por falta de datos, esto representa un modelo de seguridad débil.", "evidence": ["src/pages/index.jsx", "src/components/auth/ProtectedRoute.jsx"] }, + { "issue": "Componente de Desarrollo (`RoleSwitcher`) Presente", "impact": "BAJO. El componente `RoleSwitcher` que permite cambiar de rol arbitrariamente está presente en `Layout.jsx`. Aunque sea para desarrollo, su presencia en el código que podría llegar a producción es un riesgo de seguridad.", "evidence": ["src/pages/Layout.jsx", "src/components/dev/RoleSwitcher.jsx"] }, + { "issue": "Funcionalidad Incompleta Visible en la UI", "impact": "BAJO. Páginas como `VendorOrders` muestran un toggle para una vista 'Scheduler' que no está implementada. Lo mismo ocurre con `TaskBoard`. Esto degrada la experiencia de usuario.", "evidence": ["src/pages/VendorOrders.jsx"] }, + { "issue": "Permisos de Edición de Órdenes no Definidos", "impact": "BAJO. Un vendor puede navegar a `/EditEvent`, pero no está claro qué puede editar. Si la UI no deshabilita correctamente los campos restringidos, el vendor puede intentar guardar cambios que serán rechazados por el backend, causando frustración.", "evidence": ["src/pages/EditEvent.jsx"] } + ], + "next_verifications": [ + "AUDITAR `AddStaff.jsx`: Leer el código para encontrar el payload exacto de `base44.entities.Staff.create` y confirmar si `vendor_id` se está enviando.", + "AUDITAR `EditEvent.jsx` y su formulario `EventForm`: Determinar qué campos del formulario se deshabilitan cuando el usuario tiene el rol 'vendor'.", + "INVESTIGAR EL SCHEMA: Si es posible, revisar los archivos `.gql` de DataConnect para confirmar si hay `constraints` a nivel de base de datos que obliguen la presencia de `vendor_id` en `Staff`, `Events`, etc.", + "CLARIFICAR `user.vendor_id`: Investigar por qué el objeto `user` tendría una propiedad `vendor_id` separada de `id` y por qué `Invoices.jsx` es el único lugar que la usa." + ] +} diff --git a/frontend-web-free/vendor_workflows.json b/frontend-web-free/vendor_workflows.json new file mode 100644 index 00000000..dcca7bfd --- /dev/null +++ b/frontend-web-free/vendor_workflows.json @@ -0,0 +1,218 @@ +{ + "role": "VENDOR", + "role_detection": { + "summary": "The application identifies a VENDOR user by checking a 'role' or 'user_role' property on the user object. A mock/override mechanism using localStorage also exists for development.", + "patterns": [ + "user?.user_role === 'vendor'", + "user?.role === 'vendor'", + "user?.user_role || user?.role" + ], + "files": [ + "src/pages/Layout.jsx", + "src/pages/EditEvent.jsx", + "src/pages/StaffDirectory.jsx", + "src/components/events/ShiftCard.jsx", + "src/api/krowSDK.js" + ], + "user_object_source": "The user object is a combination of the Firebase Auth user and a profile fetched from the backend via `base44.auth.me()` (which is an alias for `krowSDK.auth.me()`). The `krowSDK.auth.me` function calls `dcSdk.getUserById({ id: fbUser.uid })`." + }, + "ownership_model": { + "vendor_id_meaning": "mixed", + "summary": "The meaning of 'vendor_id' is contextual. When a VENDOR user is logged in, their own Auth UID (`user.id`) is used as the 'vendor_id' to tag and query associated records like Staff and Orders. In other contexts (e.g., a client creating an order, or an admin managing vendors), 'vendor_id' refers to the UID of the separate vendor entity being referenced. This indicates that the User table serves as the master record for vendor entities.", + "evidence": [ + "src/pages/VendorStaff.jsx: `staff.filter(s => s.vendor_id === user?.id)`", + "src/pages/VendorOrders.jsx: `allEvents.filter(e => e.vendor_id === user?.id)`", + "src/pages/VendorCompliance.jsx: `createCertMutation.mutate({ ... vendor_id: user?.id })`", + "src/components/events/EventFormWizard.jsx: Contains logic for a non-vendor user to select a vendor, where `vendor_id` is passed.", + "src/pages/Invoices.jsx: `invoices.filter(inv => inv.vendor_id === user?.vendor_id)`. This is an INCONSISTENCY, as it uses `user.vendor_id` instead of `user.id`. This could be a bug or a property specific to staff members of a vendor." + ], + "current_workarounds_seen": [ + "The code often checks multiple properties to link data to a vendor, suggesting a lack of a single, enforced source of truth. Pattern: `e.vendor_name === user?.company_name || e.vendor_id === user?.id || e.created_by === user?.email`.", + "This multi-key check (vendor_name, vendor_id, created_by) is a risk for data integrity and indicates there may not be a strict foreign key relationship enforced at the creation of all records." + ], + "risks": [ + "Data-scoping inconsistencies (`user.id` vs `user.vendor_id`) could lead to vendors not seeing all their data (e.g., invoices).", + "Relying on `vendor_name` or `user.email` for data filtering is brittle. A name change could orphan data.", + "UI allows staff/order creation, but it's unclear if `vendor_id` is always enforced, potentially creating un-owned records." + ] + }, + "pages": [ + { + "page": "VendorDashboard", + "path": "src/pages/VendorDashboard.jsx", + "route": "/vendor-dashboard", + "purpose": "Provides a high-level, customizable overview of KPIs, recent orders, and quick actions for the vendor.", + "actions": ["View KPIs (orders, staff)", "View list of today/tomorrow's orders", "Navigate to EventDetail", "Open SmartAssignModal", "Navigate to StaffDirectory", "Customize dashboard widget layout"], + "sdk_calls": ["base44.auth.me", "base44.entities.Event.list", "base44.entities.Staff.list", "base44.auth.updateMe"] + }, + { + "page": "VendorOrders", + "path": "src/pages/VendorOrders.jsx", + "route": "/vendor-orders", + "purpose": "Primary interface for a vendor to manage all their assigned orders.", + "actions": ["Search and filter orders", "View orders by status (upcoming, active, conflicts)", "Navigate to EventDetail", "Navigate to EditEvent", "Trigger SmartAssignModal", "Detects scheduling conflicts"], + "sdk_calls": ["base44.auth.me", "base44.entities.Event.list", "base44.entities.Staff.list", "base44.entities.VendorRate.list", "base44.entities.Event.update"] + }, + { + "page": "StaffDirectory", + "path": "src/pages/StaffDirectory.jsx", + "route": "/staff-directory", + "purpose": "Allows a vendor to view and manage their own workforce.", + "actions": ["View list/grid of staff members", "Filter staff", "Navigate to AddStaff page", "Navigate to EditStaff page"], + "sdk_calls": ["base44.auth.me", "base44.entities.Staff.list", "base44.entities.Event.list"] + }, + { + "page": "VendorRates", + "path": "src/pages/VendorRates.jsx", + "route": "/vendor-rates", + "purpose": "Allows a vendor to manage their own pricing structures and custom rate cards for clients.", + "actions": ["View approved enterprise rates", "Create/edit/delete custom rate cards", "Perform AI-powered price analysis", "Export rates to CSV"], + "sdk_calls": ["base44.auth.me", "base44.entities.VendorRate.list", "base44.integrations.Core.InvokeLLM"] + }, + { + "page": "VendorCompliance", + "path": "src/pages/VendorCompliance.jsx", + "route": "/vendor-compliance", + "purpose": "Allows a vendor to manage and track certifications for their workforce.", + "actions": ["View dashboard of certificate statuses", "Add new certifications for employees", "Bulk import certificates with AI data extraction", "View certificate documents"], + "sdk_calls": ["base44.auth.me", "base44.entities.Certification.list", "base44.entities.Staff.list", "base44.entities.Certification.create", "base44.integrations.Core.UploadFile", "base44.integrations.Core.InvokeLLM"] + }, + { + "page": "Invoices", + "path": "src/pages/Invoices.jsx", + "route": "/invoices", + "purpose": "Allows a vendor to view their invoices and create new ones.", + "actions": ["View list of invoices", "Filter by status", "View invoice details", "Create new invoice from modal"], + "sdk_calls": ["base44.auth.me", "base44.entities.Invoice.list", "base44.entities.Invoice.update"] + } + ], + "workflows": [ + { + "name": "Vendor Views Orders", + "steps": [ + "1. Vendor logs in and is redirected to the dashboard.", + "2. User navigates to 'Orders' tab in the main menu.", + "3. The `VendorOrders.jsx` page loads.", + "4. A `useQuery` hook calls `base44.entities.Event.list('-date')` to fetch all events.", + "5. A `useMemo` hook filters the events where `e.vendor_id === user.id` (among other fallback conditions).", + "6. The component renders the filtered list of orders in a table, showing status, dates, and staff counts." + ] + }, + { + "name": "Vendor Edits an Order/Event", + "steps": [ + "1. From the `VendorOrders` page, the vendor clicks the 'Edit' icon on an order row.", + "2. The app navigates to `EditEvent?id={event.id}`.", + "3. The `EditEvent.jsx` page loads.", + "4. A check `const isVendor = user?.user_role === 'vendor'` is performed.", + "5. The UI is likely restricted based on this check (e.g., a vendor cannot change the client or business name). The exact restrictions are UNKNOWN without deeper analysis of the `EventForm` component.", + "6. Vendor modifies allowed fields (e.g., notes, internal fields).", + "7. Vendor clicks 'Save'. The form's `onSubmit` handler calls `base44.entities.Event.update(id, data)`." + ] + }, + { + "name": "Vendor Adds a New Staff Member", + "steps": [ + "1. Vendor navigates to the 'Workforce' page (`StaffDirectory.jsx`).", + "2. The page title confirms they are in their scoped view: 'My Staff Directory'.", + "3. Vendor clicks the 'Add New Staff' button.", + "4. The app navigates to the `AddStaff.jsx` page.", + "5. Vendor fills out the staff member's details.", + "6. On save, the `createStaff` mutation is called. It is CRITICAL that this call includes `{ vendor_id: user.id, vendor_name: user.company_name }` to correctly associate the new staff member. This is ASSUMED based on scoping logic, but not explicitly verified in `AddStaff.jsx`.", + "7. The user is redirected back to the staff directory and sees the new member." + ] + }, + { + "name": "Vendor Assigns Staff to Order using Smart Assign", + "steps": [ + "1. From `VendorDashboard` or `VendorOrders`, the vendor finds an order that is not fully staffed.", + "2. The vendor clicks the 'Smart Assign' (`UserCheck`) icon.", + "3. The `SmartAssignModal.jsx` component is opened, passing in the event object.", + "4. The modal likely displays a list of available and suitable staff from the vendor's workforce.", + "5. The vendor selects staff members from the list.", + "6. On 'Confirm', the modal's logic calls `base44.entities.Event.update` with the updated `assigned_staff` array for that event.", + "7. The table on the `VendorOrders` page refreshes to show the new `ASSIGNED` count." + ] + }, + { + "name": "Vendor Creates a Custom Rate Card", + "steps": [ + "1. Vendor navigates to 'Service Rates' (`VendorRates.jsx`).", + "2. The `VendorCompanyPricebookView` component is rendered.", + "3. Vendor clicks the 'New Card' button in the 'Custom Rate Cards' section.", + "4. The `RateCardModal.jsx` component appears.", + "5. Vendor gives the rate card a name (e.g., 'Special Client ABC Rate') and defines the rates for various roles.", + "6. On save, the new rate card is added to the component's state and becomes selectable in the UI." + ] + }, + { + "name": "Vendor Manages Staff Certifications with AI", + "steps": [ + "1. Vendor navigates to 'Compliance' (`VendorCompliance.jsx`).", + "2. Vendor decides to add a new certification and clicks 'Add Certification'.", + "3. In the dialog, instead of manually typing, vendor clicks 'Upload Document'.", + "4. An `` is triggered. Vendor selects a PDF of a ServSafe certificate.", + "5. `handleFileUpload` is called, which uploads the file via `base44.integrations.Core.UploadFile`.", + "6. On success, `validateCertificateWithAI` is called, passing the file URL to `base44.integrations.Core.InvokeLLM`.", + "7. The LLM returns a JSON object with extracted data (name, expiry date, etc.).", + "8. The form fields in the dialog are auto-populated with the AI's response.", + "9. Vendor selects the employee(s) this applies to and clicks 'Add Certification'.", + "10. `createCertMutation` is called, saving the record with the AI validation data attached." + ] + }, + { + "name": "Vendor Bulk Imports Certifications", + "steps": [ + "1. From `VendorCompliance.jsx`, vendor clicks 'Bulk Import'.", + "2. `DialogBulkImport.jsx` opens, showing a drag-and-drop area.", + "3. Vendor drops 10 PDF certificate files onto the area.", + "4. `processBulkFiles` is triggered. It iterates through each file.", + "5. For each file, it uploads it, then calls the AI to extract holder name and other data.", + "6. It then calls `findEmployeeByName` to fuzzy-match the extracted name to an employee in the `staff` list.", + "7. The UI updates with a table showing each file, the extracted name, the matched employee, and a match confidence score.", + "8. For any 'unmatched' certs, the vendor can use a dropdown to manually select the correct employee.", + "9. Vendor clicks 'Import Matched'. The system calls `createCertMutation` for each certificate that has a matched employee." + ] + }, + { + "name": "Vendor Creates an Invoice", + "steps": [ + "1. Vendor navigates to 'Invoices' (`Invoices.jsx`).", + "2. Vendor clicks 'Create Invoice'.", + "3. The `CreateInvoiceModal.jsx` opens.", + "4. The vendor can likely choose a completed event/order to generate the invoice from.", + "5. The modal is pre-populated with data from the selected event (e.g., client name, amount). This is an ASSUMPTION based on component name.", + "6. Vendor confirms the details and clicks 'Create'.", + "7. The `createInvoice` mutation is called, and the new invoice appears in the main table." + ] + } + ], + "gaps": [ + { + "area": "Event Creation", + "description": "It is unclear if a VENDOR is ever allowed to create an event. The navigation in `Layout.jsx` does not include a 'Create Event' link for vendors. However, `EditEvent.jsx` has an `isVendor` check, which implies they might interact with the form. The system seems designed for vendors to be assigned to events, not to create them.", + "files": ["src/pages/Layout.jsx", "src/pages/CreateEvent.jsx"] + }, + { + "area": "Data Creation Ownership", + "description": "While scoping on reads is clear (`vendor_id === user.id`), the enforcement during writes is not. For example, when a vendor creates a staff member via `AddStaff.jsx`, is the `vendor_id` automatically and correctly assigned on the backend or is the form responsible? This is a potential source of orphaned data if not handled robustly.", + "files": ["src/pages/AddStaff.jsx"] + }, + { + "area": "Inconsistent Data Scoping", + "description": "The `Invoices.jsx` page filters using `user.vendor_id`, while most other pages use `user.id`. This is a significant inconsistency that could cause a vendor to not see their invoices if the `user.vendor_id` property is not populated correctly on their user object.", + "files": ["src/pages/Invoices.jsx", "src/pages/VendorOrders.jsx", "src/api/krowSDK.js"] + }, + { + "area": "Scheduler View", + "description": "The `VendorOrders.jsx` page has a UI toggle for a 'scheduler' view, but the implementation for it is missing. This is a planned but incomplete feature.", + "files": ["src/pages/VendorOrders.jsx"] + } + ], + "next_verifications": [ + "Read the code for `AddStaff.jsx` to confirm that `vendor_id` is being set to `user.id` when a vendor creates a new staff record.", + "Investigate the `useQuery` hook for invoices in `Invoices.jsx` and the `user` object to understand why it uses `user.vendor_id` and determine if it's a bug.", + "Examine the `EventForm.jsx` or `EventFormWizard.jsx` component to understand exactly what fields are disabled or hidden when `isVendor` is true.", + "Check the `dataconnect/schema` to see if there is a foreign key constraint between `Staff.vendor_id` and `Users.id`." + ] +} diff --git a/frontend-web-free/workflow_table_part.txt b/frontend-web-free/workflow_table_part.txt new file mode 100644 index 00000000..716a8a6e --- /dev/null +++ b/frontend-web-free/workflow_table_part.txt @@ -0,0 +1,60 @@ + +Label menú Type Description Rute Archivo Menu Vendor Cómo se accede +Home PAGE /VendorDashboard src/pages/VendorDashboard.jsx Sí Menú (roleNavigationMap) +Home Customize OPERATION Customize vendor dashboard No Button Dashboard +Orders PAGE /VendorOrders src/pages/VendorOrders.jsx Sí Menú (roleNavigationMap) +Orders List Events OPERATION No Desde VendorOrders +Orders Event Detail PAGE /EventDetail src/pages/EventDetail.jsx No Table / Actions --> Mouse Over (View) +Orders Edit Event PAGE /EditEvent src/pages/EditEvent.jsx No Table / Actions --> Mouse Over (Edit) +Orders Update Event (ALL) OPERATION No Table / Actions --> Mouse Over (Edit)--> Edit Event +Orders Smart Assign COMPONENT src/components/events/SmartAssignModal.jsx No Table / Actions --> Mouse Over (Smart Assign) +Orders Update Event (ONLY SHIFT) OPERATION No Table / Actions --> Mouse Over (Smart Assign) --> Assign Staff +Service Rates PAGE /VendorRates src/pages/VendorRates.jsx Sí Menú (roleNavigationMap) +Service Rates AI Price Check OPERATION No Button: AI Price Check +Service Rates New Card COMPONENT src/components/rates/RateCardModal.jsx No Button: + New Card +Service Rates Create Card OPERATION No New Card --> Create Custom Rate Card +Service Rates Edit Card OPERATION No Custom Rate Cards --> Mouse Over a card (Rename) +Service Rates Delete Card OPERATION No Custom Rate Cards --> Mouse Over a card (Delete) +Invoices PAGE /Invoices src/pages/Invoices.jsx Sí Menú (roleNavigationMap) +Invoices List Invoice OPERATION No Desde Invoices +Invoices Invoice Detail PAGE /InvoiceDetail src/pages/InvoiceDetail.jsx No Table / Actions --> View +Invoices Create Invoice PAGE /Invoiceeditor No Button: + Create Invoice --> Open Invoice Editor +Invoices Create Invoice OPERATION No Button: Create Invoice +Schedule PAGE /Schedule src/pages/Schedule.jsx Sí Menú (roleNavigationMap) +Schedule View Schedule OPERATION Visualiza un calendario con eventos/shifts. No Desde Schedule +Staff Availability PAGE /StaffAvailability src/pages/StaffAvailability.jsx Sí Menú (roleNavigationMap) +Staff Availability View Staff Availability OPERATION Visualiza la disponibilidad y utilización del staff. No Desde Staff Availability +Workforce PAGE /StaffDirectory src/pages/StaffDirectory.jsx Sí Menú (roleNavigationMap) +Workforce List Staff OPERATION Visualiza la lista de miembros de staff del vendor. No Desde StaffDirectory +Workforce Add Staff OPERATION Añade un nuevo miembro de staff. No Button: Add New Staff +Workforce Edit Staff OPERATION Edita un miembro de staff existente. No Click en un miembro de staff en la lista +Onboard Staff PAGE /StaffOnboarding src/pages/StaffOnboarding.jsx Sí Menú (roleNavigationMap) +Onboard Staff Initiate Onboarding OPERATION Inicia un proceso de onboarding de 4 pasos para nuevo staff. No Desde StaffOnboarding +Onboard Staff Create Staff OPERATION Crea un nuevo registro de staff al completar el onboarding. No Último paso del Wizard: Botón 'Complete' +Onboard Staff Navigate to WorkforceDashboard OPERATION Redirige al dashboard del staff tras completar el onboarding. No Automático tras completar +Team PAGE /Teams src/pages/Teams.jsx Sí Menú (roleNavigationMap) +Team View Team OPERATION Visualiza el equipo aislado del vendor, miembros, hubs, etc. No Desde Teams +Team Invite Member OPERATION Abre un modal para invitar un nuevo miembro al equipo. No Button: Invite Member +Team Create Hub OPERATION Abre un modal para crear un nuevo hub (ubicación). No Button: Create Hub +Team Edit Member OPERATION Abre un modal para editar los detalles de un miembro. No Botón 'Edit' en la tarjeta/fila del miembro +Team Deactivate/Activate Member OPERATION Cambia el estado de un miembro del equipo. No Botones en la tarjeta/fila del miembro +Team Manage Favorites OPERATION Añade/quita staff de la lista de favoritos del equipo. No Pestaña 'Favorites' +Team Manage Blocked OPERATION Añade/quita staff de la lista de bloqueados del equipo. No Pestaña 'Blocked' +Task Board PAGE /TaskBoard src/pages/TaskBoard.jsx Sí Menú (roleNavigationMap) +Task Board List Tasks OPERATION Visualiza las tareas del equipo del vendor en un tablero Kanban. No Desde TaskBoard +Task Board Create Task OPERATION Abre un diálogo para crear una nueva tarea. No Button: Create Task +Task Board Update Task Status OPERATION Cambia el estado de una tarea arrastrándola entre columnas. No Drag & Drop entre columnas +Task Board View Task Details OPERATION Abre un modal para ver/editar detalles de una tarea. No Click en una tarjeta de tarea +Compliance PAGE /VendorCompliance src/pages/VendorCompliance.jsx Sí Menú (roleNavigationMap) +Compliance View Certifications OPERATION Visualiza las certificaciones del staff, filtrando por estado. No Desde VendorCompliance +Compliance Add Certification OPERATION Abre un diálogo para añadir una certificación individual. No Button: Add Certification +Compliance Bulk Import Certifications OPERATION Abre un diálogo para importar múltiples certificaciones en bloque. No Button: Bulk Import +Communications PAGE /Messages src/pages/Messages.jsx Sí Menú (roleNavigationMap) +Communications View Conversations OPERATION Visualiza una lista de conversaciones existentes. No Desde Messages +Communications View Message Thread OPERATION Visualiza los mensajes de una conversación seleccionada. No Seleccionar una conversación de la lista +Communications Send Message OPERATION Envía un mensaje en la conversación activa. No Componente MessageInput +Communications Create New Conversation OPERATION Abre un diálogo para iniciar una nueva conversación (individual o grupal). No Button: New Conversation +Leads PAGE /Business src/pages/Business.jsx Sí Menú (roleNavigationMap) +Leads List Businesses OPERATION Visualiza un directorio de clientes/partners de negocio. No Desde Business +Leads Add Business OPERATION Abre un modal para crear un nuevo cliente/partner de negocio. No Button: Add Business +Leads Edit Business OPERATION Edita un cliente/partner de negocio existente. No Click en 'Edit' en una BusinessCard diff --git a/frontend-web/src/components/budget/BudgetUtilizationTracker.jsx b/frontend-web/src/components/budget/BudgetUtilizationTracker.jsx new file mode 100644 index 00000000..b496bd00 --- /dev/null +++ b/frontend-web/src/components/budget/BudgetUtilizationTracker.jsx @@ -0,0 +1,401 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { + DollarSign, TrendingUp, TrendingDown, AlertTriangle, CheckCircle, + Target, Lightbulb, ArrowRight, PieChart, BarChart3, Wallet, + Building2, Users, Package, Calendar, Zap, Brain, Shield +} from "lucide-react"; + +const ROLE_BUDGET_CONFIG = { + procurement: { + title: "Procurement Budget Control", + color: "blue", + metrics: ["vendor_spend", "rate_compliance", "savings_achieved"], + focus: "Vendor rate optimization & consolidation" + }, + operator: { + title: "Operator Budget Overview", + color: "emerald", + metrics: ["sector_allocation", "labor_costs", "efficiency"], + focus: "Cross-sector resource optimization" + }, + sector: { + title: "Sector Budget Allocation", + color: "purple", + metrics: ["site_costs", "overtime", "headcount"], + focus: "Site-level cost management" + }, + client: { + title: "Your Staffing Budget", + color: "green", + metrics: ["order_costs", "vendor_rates", "savings"], + focus: "Cost-effective staffing solutions" + }, + vendor: { + title: "Revenue & Margin Tracker", + color: "amber", + metrics: ["revenue", "margins", "utilization"], + focus: "Maximize revenue & worker utilization" + }, + admin: { + title: "Platform Budget Intelligence", + color: "slate", + metrics: ["total_gmv", "platform_fees", "growth"], + focus: "Platform-wide financial health" + } +}; + +export default function BudgetUtilizationTracker({ + userRole = 'admin', + events = [], + invoices = [], + budgetData = null +}) { + const [selectedPeriod, setSelectedPeriod] = useState("month"); + const config = ROLE_BUDGET_CONFIG[userRole] || ROLE_BUDGET_CONFIG.admin; + + // Calculate budget metrics + const totalSpent = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0) || 15000; + const totalBudget = budgetData?.total_budget || totalSpent * 1.2; + const utilizationRate = ((totalSpent / totalBudget) * 100).toFixed(1); + const remainingBudget = totalBudget - totalSpent; + + // Savings calculation + const potentialSavings = totalSpent * 0.12; // 12% potential savings + const achievedSavings = totalSpent * 0.05; // 5% already achieved + + // Trend data + const trend = utilizationRate < 80 ? "under" : utilizationRate < 95 ? "on_track" : "over"; + + // Smart recommendations based on role and budget status + const getSmartRecommendations = () => { + const recommendations = []; + + if (userRole === 'procurement') { + if (utilizationRate > 90) { + recommendations.push({ + priority: "high", + action: "Renegotiate top 3 vendor contracts", + impact: `Save $${(totalSpent * 0.08).toLocaleString()}`, + icon: DollarSign + }); + } + recommendations.push({ + priority: "medium", + action: "Consolidate 5 underperforming vendors", + impact: "18% rate reduction", + icon: Package + }); + recommendations.push({ + priority: "low", + action: "Lock in Q1 rates before price increases", + impact: "Protect against 5% inflation", + icon: Shield + }); + } else if (userRole === 'operator') { + recommendations.push({ + priority: "high", + action: "Reallocate 12 workers from Sector A to B", + impact: "+15% fill rate improvement", + icon: Users + }); + recommendations.push({ + priority: "medium", + action: "Reduce overtime in kitchen roles by 20%", + impact: `Save $${(totalSpent * 0.04).toLocaleString()}/month`, + icon: TrendingDown + }); + } else if (userRole === 'sector') { + recommendations.push({ + priority: "high", + action: "Review 3 sites with >110% budget usage", + impact: "Prevent $8,200 overage", + icon: AlertTriangle + }); + recommendations.push({ + priority: "medium", + action: "Shift Tuesday/Wednesday staffing levels", + impact: "15% efficiency gain", + icon: Calendar + }); + } else if (userRole === 'client') { + recommendations.push({ + priority: "high", + action: "Switch 2 orders to Preferred Vendor rates", + impact: `Save $${(totalSpent * 0.06).toLocaleString()}`, + icon: DollarSign + }); + recommendations.push({ + priority: "medium", + action: "Lock recurring staff for top 3 positions", + impact: "15% rate lock guarantee", + icon: Shield + }); + } else if (userRole === 'vendor') { + recommendations.push({ + priority: "high", + action: "Fill 12 idle workers with pending orders", + impact: `+$${(potentialSavings * 0.8).toLocaleString()} revenue`, + icon: Users + }); + recommendations.push({ + priority: "medium", + action: "Upsell premium rates to 3 new clients", + impact: "+8% margin improvement", + icon: TrendingUp + }); + } else { + recommendations.push({ + priority: "high", + action: "Enable 2 more automation workflows", + impact: "+$12K/month platform savings", + icon: Zap + }); + recommendations.push({ + priority: "medium", + action: "Approve 5 pending vendor applications", + impact: "Unlock $45K GMV potential", + icon: Building2 + }); + } + + return recommendations; + }; + + const recommendations = getSmartRecommendations(); + + // Budget breakdown by category + const budgetBreakdown = [ + { name: "Labor Costs", amount: totalSpent * 0.65, percentage: 65, color: "bg-blue-500" }, + { name: "Vendor Fees", amount: totalSpent * 0.12, percentage: 12, color: "bg-purple-500" }, + { name: "Overtime", amount: totalSpent * 0.15, percentage: 15, color: "bg-amber-500" }, + { name: "Other", amount: totalSpent * 0.08, percentage: 8, color: "bg-slate-400" } + ]; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{config.title}

+

{config.focus}

+
+
+
+ {["week", "month", "quarter", "year"].map(period => ( + + ))} +
+
+ + {/* Main Budget Card */} + + +
+ {/* Total Spent */} +
+

Total Spent

+

${totalSpent.toLocaleString()}

+

of ${totalBudget.toLocaleString()} budget

+
+ + {/* Utilization Rate */} +
+

Utilization

+
+

+ {utilizationRate}% +

+ {trend === 'under' && } + {trend === 'on_track' && } + {trend === 'over' && } +
+ div]:bg-red-500' : trend === 'on_track' ? '[&>div]:bg-blue-500' : '[&>div]:bg-green-500'}`} + /> +
+ + {/* Remaining Budget */} +
+

Remaining

+

0 ? 'text-green-600' : 'text-red-600'}`}> + ${Math.abs(remainingBudget).toLocaleString()} +

+

+ {remainingBudget > 0 ? 'Available to spend' : 'Over budget'} +

+
+ + {/* Savings Opportunity */} +
+

+ 💡 Savings Potential +

+

${potentialSavings.toLocaleString()}

+
+ + ${achievedSavings.toLocaleString()} achieved + +
+
+
+
+
+ + {/* AI-Powered Recommendations */} + + +
+ + AI Budget Advisor + Smart Recommendations +
+ +
+ {recommendations.map((rec, idx) => { + const Icon = rec.icon; + return ( +
+
+ + + {rec.priority} + +
+

{rec.action}

+

{rec.impact}

+ +
+ ); + })} +
+
+
+ + {/* Budget Breakdown */} +
+ + + + + Spend Breakdown + + + +
+ {budgetBreakdown.map((item, idx) => ( +
+
+ {item.name} + ${item.amount.toLocaleString()} ({item.percentage}%) +
+
+
+
+
+ ))} +
+ + + + + + + + Budget Health Score + + + +
+
+ {trend === 'under' ? 'A+' : trend === 'on_track' ? 'B+' : 'C'} +
+

+ {trend === 'under' ? 'Excellent Budget Management' : trend === 'on_track' ? 'Good - Monitor Closely' : 'Action Required'} +

+

+ {trend === 'under' + ? 'You have room for strategic investments' + : trend === 'on_track' + ? 'Stay on track with current spending pace' + : 'Immediate cost reduction recommended' + } +

+
+ +
+
+

↓12%

+

vs Last Period

+
+
+

94%

+

Forecast Accuracy

+
+
+

$8.2K

+

Saved This Month

+
+
+
+
+
+ + {/* Quick Decision Panel */} + + +
+ +
+

Quick Decision for {config.title.split(' ')[0]}

+

+ {userRole === 'procurement' && "Lock in 3 vendor contracts at current rates before Q1 price increases — potential 8% savings on $125K annual spend."} + {userRole === 'operator' && "Reallocate 15% of Sector B's overtime budget to temporary staffing — same output, 22% cost reduction."} + {userRole === 'sector' && "Approve shift consolidation for Tuesdays — reduces 4 overlapping positions, saves $1,800/week."} + {userRole === 'client' && "Switch to annual contract with Preferred Vendor — locks in 15% discount, saves $6,400/year."} + {userRole === 'vendor' && "Accept 3 pending orders at standard rates — fills idle capacity, adds $12K revenue this month."} + {userRole === 'admin' && "Enable automated invoice reconciliation — reduces processing time 60%, saves $8K/month in admin costs."} +

+
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/dashboard/DashboardCustomizer.jsx b/frontend-web/src/components/dashboard/DashboardCustomizer.jsx index fc0cc12c..884c0705 100644 --- a/frontend-web/src/components/dashboard/DashboardCustomizer.jsx +++ b/frontend-web/src/components/dashboard/DashboardCustomizer.jsx @@ -23,6 +23,12 @@ import { Sparkles } from "lucide-react"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; + +// Helper to fix DnD issues +const getItemStyle = (isDragging, draggableStyle) => ({ + userSelect: "none", + ...draggableStyle, +}); import { useToast } from "@/components/ui/use-toast"; import { motion, AnimatePresence } from "framer-motion"; @@ -243,12 +249,12 @@ export default function DashboardCustomizer({
- + {(provided, snapshot) => (
diff --git a/frontend-web/src/components/events/EventFormWizard.jsx b/frontend-web/src/components/events/EventFormWizard.jsx index 72b48f1a..3fd0ed4e 100644 --- a/frontend-web/src/components/events/EventFormWizard.jsx +++ b/frontend-web/src/components/events/EventFormWizard.jsx @@ -142,7 +142,64 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm initialData: [], }); - const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort(); + // Get available roles - filter by selected vendor for clients, or by client for vendors + const availableRoles = React.useMemo(() => { + // For CLIENT users: filter roles by selected vendor + if (isClient && formData.vendor_id) { + const selectedVendor = vendors.find(v => v.id === formData.vendor_id); + const vendorName = selectedVendor?.legal_name || selectedVendor?.doing_business_as; + + // Get roles from this vendor's rates + const vendorRoles = allRates.filter(rate => + (rate.vendor_id === formData.vendor_id || rate.vendor_name === vendorName) && + rate.is_active !== false + ).map(r => r.role_name); + + // Deduplicate and sort + const uniqueRoles = [...new Set(vendorRoles)].filter(Boolean); + + if (uniqueRoles.length > 0) { + return uniqueRoles.sort(); + } + } + + // For VENDOR users: filter roles by selected client + if (isVendor && formData.business_id && formData.business_name) { + const extractCompanyName = (name) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + + const mainCompanyName = extractCompanyName(formData.business_name); + const selectedBusiness = businesses.find(b => b.id === formData.business_id); + + // Get roles that have client-specific rates for this client + const clientSpecificRoles = allRates.filter(rate => + rate.rate_book_type === 'client_specific' && + (rate.client_name === formData.business_name || + rate.client_name === mainCompanyName || + extractCompanyName(rate.client_name) === mainCompanyName) + ).map(r => r.role_name); + + // Also get roles from client's rate_card if set + let rateCardRoles = []; + if (selectedBusiness?.rate_card) { + rateCardRoles = allRates.filter(rate => + rate.rate_book_name === selectedBusiness.rate_card + ).map(r => r.role_name); + } + + // Combine and deduplicate + const combinedRoles = [...new Set([...clientSpecificRoles, ...rateCardRoles])].filter(Boolean); + + if (combinedRoles.length > 0) { + return combinedRoles.sort(); + } + } + + // Default: show all roles + return [...new Set(allRates.map(r => r.role_name))].filter(Boolean).sort(); + }, [allRates, isVendor, isClient, formData.vendor_id, formData.business_id, formData.business_name, businesses, vendors]); useEffect(() => { if (isClient && currentUserData && !formData.vendor_id) { @@ -300,19 +357,82 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm const handleBusinessChange = (businessId) => { const selectedBusiness = businesses.find(b => b.id === businessId); if (selectedBusiness) { - setFormData(prev => ({ - ...prev, - business_id: businessId, - business_name: selectedBusiness.business_name || "", - hub: selectedBusiness.hub_building || prev.hub, - shifts: prev.shifts.map(shift => ({ + // Update form with business info and recalculate rates for all roles + setFormData(prev => { + const updatedShifts = prev.shifts.map(shift => ({ ...shift, - location_address: selectedBusiness.address || shift.location_address - })) - })); + location_address: selectedBusiness.address || shift.location_address, + roles: shift.roles.map(role => { + if (role.role) { + const rate = getRateForRoleAndClient(role.role, businessId, selectedBusiness.business_name); + return { + ...role, + rate_per_hour: rate, + total_value: rate * (role.hours || 0) * (parseInt(role.count) || 1) + }; + } + return role; + }) + })); + + return { + ...prev, + business_id: businessId, + business_name: selectedBusiness.business_name || "", + hub: selectedBusiness.hub_building || prev.hub, + shifts: updatedShifts + }; + }); + setTimeout(() => updateGrandTotal(), 10); } }; + // Get rate for role based on selected client + const getRateForRoleAndClient = (roleName, businessId, businessName) => { + if (!businessId || !businessName) return 0; + + // Extract main company name (e.g., "Chime" from "Chime - HQ") + const extractCompanyName = (name) => { + if (!name) return ''; + return name.split(/\s*[-–]\s*/)[0].trim(); + }; + + const mainCompanyName = extractCompanyName(businessName); + const selectedBusiness = businesses.find(b => b.id === businessId); + + // Priority 1: Client-specific rate matching this client + const clientSpecificRate = allRates.find(rate => + rate.role_name?.toLowerCase() === roleName.toLowerCase() && + rate.rate_book_type === 'client_specific' && + (rate.client_name === businessName || + rate.client_name === mainCompanyName || + extractCompanyName(rate.client_name) === mainCompanyName) + ); + + if (clientSpecificRate) { + return clientSpecificRate.client_rate || 0; + } + + // Priority 2: Match client's rate_card + if (selectedBusiness?.rate_card) { + const rateCardMatch = allRates.find(rate => + rate.role_name?.toLowerCase() === roleName.toLowerCase() && + rate.rate_book_name === selectedBusiness.rate_card + ); + if (rateCardMatch) { + return rateCardMatch.client_rate || 0; + } + } + + // Priority 3: Default rate + const defaultRate = allRates.find(rate => + rate.role_name?.toLowerCase() === roleName.toLowerCase() && + rate.rate_book_type === 'default' + ); + + return defaultRate?.client_rate || 0; + }; + // Auto-populate shift addresses when hub changes useEffect(() => { if (formData.hub && formData.shifts.length > 0) { @@ -378,20 +498,44 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm const targetVendorId = vendorId || formData.vendor_id; if (targetVendorId) { - const rate = allRates.find(r => - r.role_name === roleName && + // Priority 1: Find rate matching vendor_id and role_name exactly + const vendorRate = allRates.find(r => + r.role_name?.toLowerCase() === roleName?.toLowerCase() && r.vendor_id === targetVendorId && r.is_active !== false ); - if (rate) { - console.log('Found rate for', roleName, 'from vendor', targetVendorId, ':', rate.client_rate); - return parseFloat(rate.client_rate || 0); + if (vendorRate) { + return parseFloat(vendorRate.client_rate || 0); + } + + // Priority 2: Find rate matching vendor_name (some rates may not have vendor_id set) + const selectedVendor = vendors.find(v => v.id === targetVendorId); + if (selectedVendor) { + const vendorName = selectedVendor.legal_name || selectedVendor.doing_business_as; + const vendorNameRate = allRates.find(r => + r.role_name?.toLowerCase() === roleName?.toLowerCase() && + r.vendor_name === vendorName && + r.is_active !== false + ); + if (vendorNameRate) { + return parseFloat(vendorNameRate.client_rate || 0); + } } } - console.log('No rate found for', roleName, 'from vendor', targetVendorId); - const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active !== false); - return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0; + // Fallback: Find any rate for this role (default rates) + const defaultRate = allRates.find(r => + r.role_name?.toLowerCase() === roleName?.toLowerCase() && + r.rate_book_type === 'default' && + r.is_active !== false + ); + if (defaultRate) { + return parseFloat(defaultRate.client_rate || 0); + } + + // Last fallback: any rate for this role + const anyRate = allRates.find(r => r.role_name?.toLowerCase() === roleName?.toLowerCase() && r.is_active !== false); + return anyRate ? parseFloat(anyRate.client_rate || 0) : 0; }; const handleRoleChange = (shiftIndex, roleIndex, field, value) => { @@ -401,8 +545,14 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm role[field] = value; if (field === 'role') { - const rate = getRateForRole(value, prev.vendor_id); - console.log('Setting rate for role', value, ':', rate, 'vendor:', prev.vendor_id); + // For vendors, use client-based rates; for clients, use vendor-based rates + let rate = 0; + if (isVendor && prev.business_id) { + rate = getRateForRoleAndClient(value, prev.business_id, prev.business_name); + } else { + rate = getRateForRole(value, prev.vendor_id); + } + console.log('Setting rate for role', value, ':', rate); role.rate_per_hour = rate; role.vendor_id = prev.vendor_id; role.vendor_name = prev.vendor_name; @@ -628,6 +778,32 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm + {isVendor && ( +
+ + + {formData.business_id && ( +

+ ✓ Client selected: {formData.business_name} +

+ )} +
+ )} + {isClient && (
- {isVendor && ( -
- - -
- )} +
{ + if (invoice) { + setEditedDetails({ + event_name: invoice.event_name || "", + po_reference: invoice.po_reference || "", + event_date: invoice.event_date || "", + due_date: invoice.due_date || "", + from_company: { + name: invoice.from_company?.name || invoice.vendor_name || "", + address: invoice.from_company?.address || "", + email: invoice.from_company?.email || "", + phone: invoice.from_company?.phone || "" + }, + to_company: { + name: invoice.to_company?.name || invoice.business_name || "", + address: invoice.to_company?.address || "", + email: invoice.to_company?.email || "", + manager: invoice.to_company?.manager || invoice.manager_name || "", + phone: invoice.to_company?.phone || "", + vendor_id: invoice.to_company?.vendor_id || "" + } + }); + } + }, [invoice]); const updateInvoiceMutation = useMutation({ mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data), @@ -87,7 +126,8 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { }; const handleEditInvoice = () => { - navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`)); + const disputedIndices = invoice.disputed_items?.map(item => item.staff_index).join(',') || ''; + navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}&disputed=${disputedIndices}`)); }; const handleDispute = async () => { @@ -110,6 +150,254 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { window.print(); }; + const handleSaveDetails = () => { + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { + event_name: editedDetails.event_name, + po_reference: editedDetails.po_reference, + event_date: editedDetails.event_date, + due_date: editedDetails.due_date, + from_company: editedDetails.from_company, + to_company: editedDetails.to_company, + business_name: editedDetails.to_company.name, + manager_name: editedDetails.to_company.manager, + vendor_name: editedDetails.from_company.name, + } + }); + setIsEditingDetails(false); + }; + + const handleCancelEdit = () => { + setEditedDetails({ + event_name: invoice.event_name || "", + po_reference: invoice.po_reference || "", + event_date: invoice.event_date || "", + due_date: invoice.due_date || "", + from_company: { + name: invoice.from_company?.name || invoice.vendor_name || "", + address: invoice.from_company?.address || "", + email: invoice.from_company?.email || "", + phone: invoice.from_company?.phone || "" + }, + to_company: { + name: invoice.to_company?.name || invoice.business_name || "", + address: invoice.to_company?.address || "", + email: invoice.to_company?.email || "", + manager: invoice.to_company?.manager || invoice.manager_name || "", + phone: invoice.to_company?.phone || "", + vendor_id: invoice.to_company?.vendor_id || "" + } + }); + setIsEditingDetails(false); + }; + + const handleEditEntry = (roleIdx, entryIdx, entry) => { + setEditingEntryIndex({ roleIdx, entryIdx }); + setEditedEntry({ ...entry }); + }; + + const handleSaveEntry = () => { + if (!editingEntryIndex || !editedEntry) return; + + const updatedRoles = [...(invoice.roles || displayRoles)]; + if (updatedRoles[editingEntryIndex.roleIdx]?.staff_entries) { + updatedRoles[editingEntryIndex.roleIdx].staff_entries[editingEntryIndex.entryIdx] = editedEntry; + updatedRoles[editingEntryIndex.roleIdx].role_subtotal = updatedRoles[editingEntryIndex.roleIdx].staff_entries.reduce( + (sum, e) => sum + (e.total || 0), 0 + ); + } + + const newSubtotal = updatedRoles.reduce((sum, r) => sum + (r.role_subtotal || 0), 0); + const newAmount = newSubtotal + (invoice.other_charges || 0); + + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { + roles: updatedRoles, + subtotal: newSubtotal, + amount: newAmount + } + }); + setEditingEntryIndex(null); + setEditedEntry(null); + }; + + const handleRemoveEntry = (roleIdx, entryIdx) => { + const updatedRoles = [...(invoice.roles || displayRoles)]; + if (updatedRoles[roleIdx]?.staff_entries) { + updatedRoles[roleIdx].staff_entries.splice(entryIdx, 1); + updatedRoles[roleIdx].role_subtotal = updatedRoles[roleIdx].staff_entries.reduce( + (sum, e) => sum + (e.total || 0), 0 + ); + } + + const newSubtotal = updatedRoles.reduce((sum, r) => sum + (r.role_subtotal || 0), 0); + const newAmount = newSubtotal + (invoice.other_charges || 0); + + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { + roles: updatedRoles, + subtotal: newSubtotal, + amount: newAmount + } + }); + }; + + const handleAddEntry = () => { + const newEntry = { + staff_name: "", + date: new Date().toISOString().split('T')[0], + position: "", + check_in: "", + check_out: "", + worked_hours: 0, + regular_hours: 0, + ot_hours: 0, + dt_hours: 0, + rate: 0, + regular_value: 0, + ot_value: 0, + dt_value: 0, + total: 0 + }; + + const updatedRoles = [...(invoice.roles || displayRoles)]; + if (updatedRoles.length === 0) { + updatedRoles.push({ role_name: "Staff", staff_entries: [], role_subtotal: 0 }); + } + updatedRoles[0].staff_entries.push(newEntry); + + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { roles: updatedRoles } + }); + + // Start editing the new entry + setTimeout(() => { + setEditingEntryIndex({ roleIdx: 0, entryIdx: updatedRoles[0].staff_entries.length - 1 }); + setEditedEntry(newEntry); + }, 100); + }; + + const handleFlagEntry = async (roleIdx, entryIdx, entry) => { + const user = await base44.auth.me(); + const flaggedItems = [...(invoice.disputed_items || [])]; + const existingIndex = flaggedItems.findIndex(item => item.role_index === roleIdx && item.staff_index === entryIdx); + + if (existingIndex === -1) { + // Add flag with details + flaggedItems.push({ + role_index: roleIdx, + staff_index: entryIdx, + staff_name: entry?.staff_name || entry?.name || 'Unknown', + position: entry?.position || 'Unknown', + flagged_by: user.email, + flagged_by_role: user.user_role || user.role, + flagged_at: new Date().toISOString(), + worked_hours: entry?.worked_hours, + total: entry?.total + }); + } else { + // Remove flag if already flagged (toggle) + flaggedItems.splice(existingIndex, 1); + } + + updateInvoiceMutation.mutate({ + id: invoice.id, + data: { disputed_items: flaggedItems } + }); + }; + + const parseTimeToMinutes = (timeStr) => { + if (!timeStr || timeStr === "hh:mm" || timeStr === "—") return null; + + // Try format: "06:00 AM" or "02:30 PM" + let match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i); + if (match) { + let hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + const period = match[3].toUpperCase(); + if (period === "PM" && hours !== 12) hours += 12; + if (period === "AM" && hours === 12) hours = 0; + return hours * 60 + minutes; + } + + // Try 24-hour format: "06:00" or "14:30" + match = timeStr.match(/(\d{1,2}):(\d{2})/); + if (match) { + const hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + return hours * 60 + minutes; + } + + return null; + }; + + const recalculateEntry = (entry) => { + // Calculate worked hours from check_in and check_out + let workedHours = entry.worked_hours || 0; + let nbBonusHour = 0; + const startMinutes = parseTimeToMinutes(entry.check_in); + const endMinutes = parseTimeToMinutes(entry.check_out); + + if (startMinutes !== null && endMinutes !== null) { + let totalMinutes = endMinutes - startMinutes; + if (totalMinutes < 0) totalMinutes += 24 * 60; // Handle overnight shifts + + // NB = No Break, adds 1 extra REGULAR hour (not OT) + // Other breaks >= 20 minutes are deducted + const lunchVal = entry.lunch; + if (lunchVal === "NB") { + nbBonusHour = 1; // NB adds 1 extra regular hour + } else { + const lunchMinutes = (lunchVal === 0 || lunchVal === "0") ? 0 : + (typeof lunchVal === 'number' && lunchVal >= 20) ? lunchVal : + (typeof lunchVal === 'string' && parseInt(lunchVal) >= 20) ? parseInt(lunchVal) : 0; + totalMinutes -= lunchMinutes; + } + workedHours = Math.max(0, totalMinutes / 60); + } + + // Actual consecutive hours worked (without NB bonus) determines OT/DT + const consecutiveHours = workedHours; + + // Distribute hours: first 8 regular, next 4 OT, rest DT (based on consecutive hours) + let regularHours = 0, otHours = 0, dtHours = 0; + if (consecutiveHours <= 8) { + regularHours = consecutiveHours; + } else if (consecutiveHours <= 12) { + regularHours = 8; + otHours = consecutiveHours - 8; + } else { + regularHours = 8; + otHours = 4; + dtHours = consecutiveHours - 12; + } + + // Add NB bonus to regular hours only + regularHours += nbBonusHour; + workedHours += nbBonusHour; + + const rate = parseFloat(entry.rate) || 0; + const regValue = regularHours * rate; + const otValue = otHours * rate * 1.5; + const dtValue = dtHours * rate * 2; + + return { + ...entry, + worked_hours: parseFloat(workedHours.toFixed(2)), + regular_hours: parseFloat(regularHours.toFixed(2)), + ot_hours: parseFloat(otHours.toFixed(2)), + dt_hours: parseFloat(dtHours.toFixed(2)), + regular_value: parseFloat(regValue.toFixed(2)), + ot_value: parseFloat(otValue.toFixed(2)), + dt_value: parseFloat(dtValue.toFixed(2)), + total: parseFloat((regValue + otValue + dtValue).toFixed(2)) + }; + }; + const toggleItemSelection = (roleIndex, staffIndex) => { const itemId = { role_index: roleIndex, staff_index: staffIndex }; setSelectedItems(prev => { @@ -123,13 +411,72 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) { if (!invoice) return null; + // Generate demo staff entries if none exist + const demoStaffEntries = [ + { staff_name: "Maria Garcia", date: invoice.issue_date || "2025-10-15", position: "Bartender", check_in: "09:00 AM", check_out: "05:00 PM", worked_hours: 8, regular_hours: 8, ot_hours: 0, dt_hours: 0, rate: 52.68, regular_value: 421.44, ot_value: 0, dt_value: 0, total: 421.44 }, + { staff_name: "Carlos Rodriguez", date: invoice.issue_date || "2025-10-15", position: "Server", check_in: "10:00 AM", check_out: "06:30 PM", worked_hours: 8.5, regular_hours: 8, ot_hours: 0.5, dt_hours: 0, rate: 45.00, regular_value: 360.00, ot_value: 33.75, dt_value: 0, total: 393.75 }, + { staff_name: "Ana Martinez", date: invoice.issue_date || "2025-10-15", position: "Line Cook", check_in: "07:00 AM", check_out: "04:00 PM", worked_hours: 9, regular_hours: 8, ot_hours: 1, dt_hours: 0, rate: 48.50, regular_value: 388.00, ot_value: 72.75, dt_value: 0, total: 460.75 }, + ]; + + const demoRoles = [{ + role_name: "Mixed", + staff_entries: demoStaffEntries, + role_subtotal: demoStaffEntries.reduce((sum, e) => sum + e.total, 0) + }]; + + // Use real roles if available, otherwise use demo data + const displayRoles = (invoice.roles && invoice.roles.length > 0 && invoice.roles[0]?.staff_entries?.length > 0) + ? invoice.roles + : demoRoles; + + // Group entries by position for collapsible view + const getGroupedByPosition = (staffEntries) => { + const groups = {}; + staffEntries.forEach((entry, idx) => { + const position = entry.position || 'Other'; + if (!groups[position]) { + groups[position] = { + position, + entries: [], + totalWorkedHours: 0, + totalRegularHours: 0, + totalOtHours: 0, + totalDtHours: 0, + totalRegularValue: 0, + totalOtValue: 0, + totalDtValue: 0, + total: 0 + }; + } + groups[position].entries.push({ ...entry, originalIndex: idx }); + groups[position].totalWorkedHours += entry.worked_hours || 0; + groups[position].totalRegularHours += entry.regular_hours || 0; + groups[position].totalOtHours += entry.ot_hours || 0; + groups[position].totalDtHours += entry.dt_hours || 0; + groups[position].totalRegularValue += entry.regular_value || 0; + groups[position].totalOtValue += entry.ot_value || 0; + groups[position].totalDtValue += entry.dt_value || 0; + groups[position].total += entry.total || 0; + }); + return Object.values(groups); + }; + + const togglePositionExpand = (roleIdx, position) => { + const key = `${roleIdx}-${position}`; + setExpandedPositions(prev => ({ ...prev, [key]: !prev[key] })); + }; + + const displaySubtotal = invoice.subtotal || displayRoles.reduce((sum, r) => sum + (r.role_subtotal || 0), 0); + const displayAmount = invoice.amount || (displaySubtotal + (invoice.other_charges || 0)); + const isClient = userRole === "client"; const isVendor = userRole === "vendor"; const isAdmin = userRole === "admin"; - const canEdit = (isVendor || isAdmin) && ["Draft", "Pending Review", "Disputed"].includes(invoice.status); - const canApprove = isClient && invoice.status === "Pending Review"; - const canPay = isClient && invoice.status === "Approved"; - const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status); + const canEdit = isVendor || isAdmin; + const canApprove = (isClient || isAdmin) && invoice.status === "Pending Review"; + const canPay = (isClient || isAdmin) && invoice.status === "Approved"; + const canDispute = (isClient || isAdmin) && ["Pending Review", "Approved"].includes(invoice.status); + const canFlag = isClient || isAdmin; return (
@@ -179,30 +526,155 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {
{/* Event Info */} -
-
- Event Name: {invoice.event_name} + {isEditingDetails ? ( +
+
+ + setEditedDetails({ ...editedDetails, event_name: e.target.value })} + className="h-8 mt-1" + /> +
+
+ + setEditedDetails({ ...editedDetails, po_reference: e.target.value })} + className="h-8 mt-1" + /> +
+
+ + setEditedDetails({ ...editedDetails, event_date: e.target.value })} + className="h-8 mt-1" + /> +
+
+ + setEditedDetails({ ...editedDetails, due_date: e.target.value })} + className="h-8 mt-1" + /> +
-
- PO#: {invoice.po_reference || "N/A"} + ) : ( +
+
+ Event Name: {invoice.event_name} +
+
+ PO#: {invoice.po_reference || "N/A"} +
+
+ Date: {invoice.event_date ? format(parseISO(invoice.event_date), 'M.d.yyyy') : '—'} +
+
+ Due date: {format(parseISO(invoice.due_date), 'M.d.yyyy')} +
-
- Date: {invoice.event_date ? format(parseISO(invoice.event_date), 'M.d.yyyy') : '—'} + )} + + {/* Inline Edit Controls for Vendor */} + {canEdit && ( +
+ {isEditingDetails ? ( + <> + + + + ) : ( + + )}
-
- Due date: {format(parseISO(invoice.due_date), 'M.d.yyyy')} -
-
+ )}
- {/* KROW Logo */} -
- KROW -
+ + + {/* Dispute Alert for Vendors */} + {invoice.status === "Disputed" && (isVendor || isAdmin) && ( +
+
+
+ +
+
+

Invoice Disputed by Client

+
+
+

Reason

+

{invoice.dispute_reason || "Not specified"}

+
+
+

Disputed By

+

{invoice.disputed_by || "Client"}

+
+
+ {invoice.dispute_details && ( +
+

Details

+

{invoice.dispute_details}

+
+ )} + {invoice.disputed_items && invoice.disputed_items.length > 0 && ( +
+

Disputed Line Items ({invoice.disputed_items.length})

+
+ + + + + + + + + + + + {invoice.disputed_items.map((item, idx) => { + const role = displayRoles[item.role_index]; + const entry = role?.staff_entries?.[item.staff_index]; + if (!entry) return null; + return ( + + + + + + + + ); + })} + +
#StaffPositionHoursAmount
{idx + 1}{entry.staff_name || entry.name || "Staff"}{entry.position}{entry.worked_hours?.toFixed(2)}${entry.total?.toFixed(2)}
+
+
+ )} +
+ +
+
+
+
+ )} {/* From and To */}
@@ -213,33 +685,76 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {

From:

-
-

{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}

- {(invoice.from_company?.address || invoice.vendor_address) && ( -
-

Address

-

{invoice.from_company?.address || invoice.vendor_address}

+ {isEditingDetails ? ( +
+
+ + setEditedDetails({ + ...editedDetails, + from_company: { ...editedDetails.from_company, name: e.target.value } + })} + className="h-8 mt-1" + />
- )} - {(invoice.from_company?.email || invoice.vendor_email) && ( -
-

Email

-

{invoice.from_company?.email || invoice.vendor_email}

+
+ + setEditedDetails({ + ...editedDetails, + from_company: { ...editedDetails.from_company, address: e.target.value } + })} + className="h-8 mt-1" + />
- )} - {(invoice.from_company?.phone || invoice.vendor_phone) && ( -
-

Phone

-

{invoice.from_company?.phone || invoice.vendor_phone}

+
+ + setEditedDetails({ + ...editedDetails, + from_company: { ...editedDetails.from_company, email: e.target.value } + })} + className="h-8 mt-1" + />
- )} - {(invoice.from_company?.contact || invoice.vendor_contact) && ( -
-

Point of Contact

-

{invoice.from_company?.contact || invoice.vendor_contact}

+
+ + setEditedDetails({ + ...editedDetails, + from_company: { ...editedDetails.from_company, phone: e.target.value } + })} + className="h-8 mt-1" + />
- )} -
+
+ ) : ( +
+

{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}

+ {(invoice.from_company?.address || invoice.vendor_address) && ( +
+

Address

+

{invoice.from_company?.address || invoice.vendor_address}

+
+ )} + {(invoice.from_company?.email || invoice.vendor_email) && ( +
+

Email

+

{invoice.from_company?.email || invoice.vendor_email}

+
+ )} + {(invoice.from_company?.phone || invoice.vendor_phone) && ( +
+

Phone

+

{invoice.from_company?.phone || invoice.vendor_phone}

+
+ )} +
+ )}
@@ -249,159 +764,612 @@ export default function InvoiceDetailView({ invoice, userRole, onClose }) {

To:

-
-

{invoice.to_company?.name || invoice.business_name}

-

{invoice.to_company?.address}

-

{invoice.to_company?.email}

-
+ {isEditingDetails ? ( +
-

Main Kitchen

-

{invoice.to_company?.manager || invoice.manager_name}

+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, name: e.target.value } + })} + className="h-8 mt-1" + />
-

Manager Name

-

{invoice.to_company?.phone}

+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, address: e.target.value } + })} + className="h-8 mt-1" + /> +
+
+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, email: e.target.value } + })} + className="h-8 mt-1" + /> +
+
+
+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, manager: e.target.value } + })} + className="h-8 mt-1" + /> +
+
+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, phone: e.target.value } + })} + className="h-8 mt-1" + /> +
+
+
+ + setEditedDetails({ + ...editedDetails, + to_company: { ...editedDetails.to_company, vendor_id: e.target.value } + })} + className="h-8 mt-1" + />
-

{invoice.to_company?.vendor_id || "Vendor #"}

-
+ ) : ( +
+

{invoice.to_company?.name || invoice.business_name}

+

{invoice.to_company?.address}

+

{invoice.to_company?.email}

+
+
+

Main Kitchen

+

{invoice.to_company?.manager || invoice.manager_name}

+
+
+

Manager Name

+

{invoice.to_company?.phone}

+
+
+

{invoice.to_company?.vendor_id || "Vendor #"}

+
+ )}
{/* Staff Charges Table */}
+ {canEdit && ( +
+

Staff Entries

+ +
+ )}
- +
- - - - - - - - - - - - + + + + + + + + + + + + - {invoice.roles?.map((roleGroup, roleIdx) => ( - - {roleGroup.staff_entries?.map((entry, entryIdx) => ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + {/* Expanded Individual Entries - Sub-table with detailed columns */} + {isExpanded && ( + + + + )} + + ); + })} + {/* Total Row */} + + + + + + + + + + + + + - ))} - - - - - - - ))} + + ); + })}
#DatePositionWorked HoursReg HoursOT HoursDT HoursReg ValueOT ValueDT ValueTotalActions#DatePositionWorked HoursReg HoursOT HoursDT HoursReg ValueOT ValueDT ValueTotalActions
{roleIdx + 1}{entry.date ? format(parseISO(entry.date), 'M/d/yyyy') : '—'}{entry.position}{entry.worked_hours?.toFixed(2) || '0.00'}{entry.regular_hours?.toFixed(2) || '0.00'}{entry.ot_hours?.toFixed(2) || '0.00'}{entry.dt_hours?.toFixed(2) || '0.00'}${entry.regular_value?.toFixed(2) || '0.00'}${entry.ot_value?.toFixed(2) || '0.00'}${entry.dt_value?.toFixed(2) || '0.00'}${entry.total?.toFixed(2) || '0.00'} - - - - - - View Details - Flag Entry - - + {displayRoles?.map((roleGroup, roleIdx) => { + const groupedPositions = getGroupedByPosition(roleGroup.staff_entries || []); + let rowNumber = 0; + + return ( + + {groupedPositions.map((posGroup, posIdx) => { + const isExpanded = expandedPositions[`${roleIdx}-${posGroup.position}`]; + const hasMultiple = posGroup.entries.length > 1; + rowNumber++; + const currentRowNum = rowNumber; + + return ( + + {/* Position Summary Row */} +
{currentRowNum} + {hasMultiple ? '—' : (() => { + const dateStr = posGroup.entries[0]?.date; + if (!dateStr) return '—'; + try { return format(parseISO(dateStr), 'MM.dd.yyyy'); } catch { return dateStr; } + })()} + {posGroup.position}{posGroup.totalWorkedHours.toFixed(2)}{posGroup.totalRegularHours.toFixed(2)}{posGroup.totalOtHours.toFixed(2)}{posGroup.totalDtHours.toFixed(2)}${posGroup.totalRegularValue.toFixed(2)}${posGroup.totalOtValue.toFixed(2)}${posGroup.totalDtValue.toFixed(2)}${posGroup.total.toFixed(2)} +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + {posGroup.entries.map((entry, entryIdx) => { + const isFlagged = invoice.disputed_items?.some(item => item.role_index === roleIdx && item.staff_index === entry.originalIndex); + return ( + + + + + + + + + + + + + + + + + + ); + })} + {/* Sub-table Total Row */} + + + + + + + + + + + + + + + + + + +
#NameClockinLunchClockoutWorked HoursReg HoursOT HoursDT HoursRateReg ValueOT ValueDT ValueTotalActions
{entryIdx + 1} + {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( +
+ setEditedEntry({...editedEntry, staff_name: e.target.value, name: e.target.value})} + className="h-7 w-20 text-xs" + placeholder="Name" + /> + +
+ ) : (entry.staff_name || entry.name || '—')} +
+ {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( + { + const updated = {...editedEntry, check_in: e.target.value}; + setEditedEntry(recalculateEntry(updated)); + }} + placeholder="09:00 AM" + className="h-7 w-20 text-xs" + /> + ) : (entry.check_in || '—')} + + {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( + + ) : (entry.lunch || '0')} + + {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( + { + const updated = {...editedEntry, check_out: e.target.value}; + setEditedEntry(recalculateEntry(updated)); + }} + placeholder="05:00 PM" + className="h-7 w-20 text-xs" + /> + ) : (entry.check_out || '—')} + {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.worked_hours?.toFixed(2) : entry.worked_hours?.toFixed(2) || '0.00'}{editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.regular_hours?.toFixed(2) : entry.regular_hours?.toFixed(2) || '0.00'}{editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.ot_hours?.toFixed(2) : entry.ot_hours?.toFixed(2) || '0.00'}{editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.dt_hours?.toFixed(2) : entry.dt_hours?.toFixed(2) || '0.00'} + {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( + { + const updated = {...editedEntry, rate: parseFloat(e.target.value) || 0}; + setEditedEntry(recalculateEntry(updated)); + }} + className="h-7 w-16 text-xs text-right" + /> + ) : `$${entry.rate?.toFixed(2) || '0.00'}`} + ${editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.regular_value?.toFixed(2) : entry.regular_value?.toFixed(2) || '0.00'}${editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.ot_value?.toFixed(2) : entry.ot_value?.toFixed(2) || '0.00'}${editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.dt_value?.toFixed(2) : entry.dt_value?.toFixed(2) || '0.00'}${editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? editedEntry?.total?.toFixed(2) : entry.total?.toFixed(2) || '0.00'} +
+ {editingEntryIndex?.roleIdx === roleIdx && editingEntryIndex?.entryIdx === entry.originalIndex ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
Total{posGroup.totalWorkedHours.toFixed(2)}{posGroup.totalRegularHours.toFixed(2)}{posGroup.totalOtHours.toFixed(2)}{posGroup.totalDtHours.toFixed(2)}${posGroup.totalRegularValue.toFixed(2)}${posGroup.totalOtValue.toFixed(2)}${posGroup.totalDtValue.toFixed(2)}${posGroup.total.toFixed(2)}
+
Total + {groupedPositions.reduce((sum, g) => sum + g.totalWorkedHours, 0).toFixed(2)} + {groupedPositions.reduce((sum, g) => sum + g.totalRegularHours, 0).toFixed(2)} + + {groupedPositions.reduce((sum, g) => sum + g.totalOtHours, 0).toFixed(2)} + + {groupedPositions.reduce((sum, g) => sum + g.totalDtHours, 0).toFixed(2)} + + ${groupedPositions.reduce((sum, g) => sum + g.totalRegularValue, 0).toFixed(2)} + + ${groupedPositions.reduce((sum, g) => sum + g.totalOtValue, 0).toFixed(2)} + + ${groupedPositions.reduce((sum, g) => sum + g.totalDtValue, 0).toFixed(2)} + + ${roleGroup.role_subtotal?.toFixed(2)} +
Total${roleGroup.role_subtotal?.toFixed(2)}
- {/* Other Charges */} -
-
-

Other charges

-
- - - - - - - - - - - - {(!invoice.other_charges || invoice.other_charges === 0) ? ( + {/* Other Charges and Total Section - Side by Side */} +
+ {/* Other Charges */} +
+
+

Other charges

+
+
#ChargeQTYRateAmount
+ - + + + + + - ) : ( - - - - - - - - )} - -
- No additional charges - #ChargeQTYRateAmount
1Additional Charges1${invoice.other_charges?.toFixed(2)}${invoice.other_charges?.toFixed(2)}
-
+ + + {invoice.other_charges > 0 ? ( + + 1 + Additional Charges + 1 + ${invoice.other_charges?.toFixed(2)} + ${invoice.other_charges?.toFixed(2)} + + ) : ( + + No other charges + + )} + + +
- {/* Totals */} -
-
-
- Sub-total: - ${invoice.subtotal?.toFixed(2)} + {/* Total */} +
+
+

Total

-
- Other charges: - ${(invoice.other_charges || 0)?.toFixed(2)} -
-
- Grand total: - ${invoice.amount?.toFixed(2)} +
+
+ Sub total: + ${displaySubtotal?.toFixed(2)} +
+
+ Other charges: + ${(invoice.other_charges || 0)?.toFixed(2)} +
+
+ Grand total: + ${displayAmount?.toFixed(2)} +
{/* Footer */}
- KROW +
+ Invoice powered by + KROW +
Page 1
{/* Dispute Dialog */} - + - Dispute Invoice + + + Dispute Invoice - Select Items +
+ {/* Select items to dispute */} +
+ +
+ + + + + + + + + + + + {displayRoles.map((role, rIdx) => ( + role.staff_entries?.map((entry, sIdx) => { + const isSelected = selectedItems.some( + item => item.role_index === rIdx && item.staff_index === sIdx + ); + return ( + toggleItemSelection(rIdx, sIdx)} + > + + + + + + + ); + }) + ))} + +
+ { + if (e.target.checked) { + const allItems = []; + displayRoles.forEach((role, rIdx) => { + role.staff_entries?.forEach((_, sIdx) => { + allItems.push({ role_index: rIdx, staff_index: sIdx }); + }); + }); + setSelectedItems(allItems); + } else { + setSelectedItems([]); + } + }} + /> + StaffPositionHoursTotal
+ toggleItemSelection(rIdx, sIdx)} + className="rounded" + /> + {entry.staff_name || entry.name || "Staff Member"}{entry.position}{entry.worked_hours?.toFixed(2)}${entry.total?.toFixed(2)}
+
+ {selectedItems.length > 0 && ( +

+ {selectedItems.length} item(s) selected for dispute +

+ )} +
+
- +