Merge pull request #191 from Oloodi/190-back-update-user-entity-in-data-connect-to-match-staff_app_mvp

190 back update user entity in data connect to match staff app mvp
This commit is contained in:
José Salazar
2025-12-26 15:21:16 -05:00
committed by GitHub
59 changed files with 16375 additions and 3097 deletions

View File

@@ -3,7 +3,8 @@ mutation CreateUser(
$email: String!,
$fullName: String!,
$role: UserBaseRole!,
$userRole: String
$userRole: String,
$photoUrl: String
) @auth(level: USER) {
user_insert(
data: {
@@ -12,6 +13,7 @@ mutation CreateUser(
fullName: $fullName
role: $role
userRole: $userRole
photoUrl: $photoUrl
}
)
}
@@ -21,7 +23,8 @@ mutation UpdateUser(
$email: String,
$fullName: String,
$role: UserBaseRole,
$userRole: String
$userRole: String,
$photoUrl: String
) @auth(level: USER) {
user_update(
id: $id,
@@ -30,6 +33,7 @@ mutation UpdateUser(
fullName: $fullName
role: $role
userRole: $userRole
photoUrl: $photoUrl
}
)
}

View File

@@ -5,6 +5,7 @@ query listUsers @auth(level: USER) {
fullName
role
userRole
photoUrl
createdDate
updatedDate
}
@@ -19,6 +20,7 @@ query getUserById(
fullName
role
userRole
photoUrl
}
}
@@ -41,5 +43,6 @@ query filterUsers(
fullName
role
userRole
photoUrl
}
}

View File

@@ -9,6 +9,7 @@ type User @table(name: "users") {
fullName: String!
role: UserBaseRole!
userRole: String
photoUrl: String
createdDate: Timestamp @default(expr: "request.time")
updatedDate: Timestamp @default(expr: "request.time")
createdBy: String @default(expr: "auth.uid")

View File

@@ -644,7 +644,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
<SelectValue placeholder="Choose vendor for this order" />
</SelectTrigger>
<SelectContent>
{vendors.filter(v => v.approval_status === 'approved').map((vendor) => (
{vendors.filter(v => v.approval_status === 'APPROVED').map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.legal_name || vendor.doing_business_as}
{currentUserData?.preferred_vendor_id === vendor.id && (

View File

@@ -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() {
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
<strong>Isolated Team:</strong> 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."}
</p>
</div>

View File

@@ -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."
]
}

View File

@@ -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."
]
}

View File

@@ -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 `<input type='file'>` 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`."
]
}

View File

@@ -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

View File

@@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 bg-${config.color}-100 rounded-xl flex items-center justify-center`}>
<Wallet className={`w-6 h-6 text-${config.color}-600`} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">{config.title}</h2>
<p className="text-sm text-slate-500">{config.focus}</p>
</div>
</div>
<div className="flex gap-2">
{["week", "month", "quarter", "year"].map(period => (
<Button
key={period}
size="sm"
variant={selectedPeriod === period ? "default" : "outline"}
onClick={() => setSelectedPeriod(period)}
className={selectedPeriod === period ? "bg-[#0A39DF]" : ""}
>
{period.charAt(0).toUpperCase() + period.slice(1)}
</Button>
))}
</div>
</div>
{/* Main Budget Card */}
<Card className={`border-2 ${trend === 'under' ? 'border-green-200 bg-green-50/30' : trend === 'on_track' ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30'}`}>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Total Spent */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Total Spent</p>
<p className="text-3xl font-bold text-slate-900">${totalSpent.toLocaleString()}</p>
<p className="text-sm text-slate-500">of ${totalBudget.toLocaleString()} budget</p>
</div>
{/* Utilization Rate */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Utilization</p>
<div className="flex items-center gap-2">
<p className={`text-3xl font-bold ${trend === 'under' ? 'text-green-600' : trend === 'on_track' ? 'text-blue-600' : 'text-red-600'}`}>
{utilizationRate}%
</p>
{trend === 'under' && <TrendingDown className="w-5 h-5 text-green-500" />}
{trend === 'on_track' && <Target className="w-5 h-5 text-blue-500" />}
{trend === 'over' && <TrendingUp className="w-5 h-5 text-red-500" />}
</div>
<Progress
value={parseFloat(utilizationRate)}
className={`h-2 mt-2 ${trend === 'over' ? '[&>div]:bg-red-500' : trend === 'on_track' ? '[&>div]:bg-blue-500' : '[&>div]:bg-green-500'}`}
/>
</div>
{/* Remaining Budget */}
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Remaining</p>
<p className={`text-3xl font-bold ${remainingBudget > 0 ? 'text-green-600' : 'text-red-600'}`}>
${Math.abs(remainingBudget).toLocaleString()}
</p>
<p className="text-sm text-slate-500">
{remainingBudget > 0 ? 'Available to spend' : 'Over budget'}
</p>
</div>
{/* Savings Opportunity */}
<div className="bg-purple-50 rounded-xl p-4 border-2 border-purple-200">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold mb-1">
💡 Savings Potential
</p>
<p className="text-2xl font-bold text-purple-700">${potentialSavings.toLocaleString()}</p>
<div className="flex items-center gap-2 mt-1">
<Badge className="bg-green-100 text-green-700 text-[10px]">
${achievedSavings.toLocaleString()} achieved
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* AI-Powered Recommendations */}
<Card className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white overflow-hidden">
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Brain className="w-5 h-5" />
<span className="font-bold">AI Budget Advisor</span>
<Badge className="bg-white/20 text-white border-0 text-[10px]">Smart Recommendations</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{recommendations.map((rec, idx) => {
const Icon = rec.icon;
return (
<div
key={idx}
className={`p-4 rounded-xl ${rec.priority === 'high' ? 'bg-white/20' : 'bg-white/10'} backdrop-blur-sm`}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" />
<Badge className={`text-[10px] ${rec.priority === 'high' ? 'bg-red-500' : rec.priority === 'medium' ? 'bg-amber-500' : 'bg-green-500'}`}>
{rec.priority}
</Badge>
</div>
<p className="text-sm font-medium mb-1">{rec.action}</p>
<p className="text-xs text-white/70">{rec.impact}</p>
<Button
size="sm"
variant="secondary"
className="mt-2 h-7 text-xs bg-white text-[#0A39DF] hover:bg-white/90"
onClick={() => window.location.href = rec.priority === 'high' ? '/VendorManagement' : '/Reports'}
>
Take Action <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Budget Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<PieChart className="w-5 h-5 text-[#0A39DF]" />
Spend Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{budgetBreakdown.map((item, idx) => (
<div key={idx}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{item.name}</span>
<span className="text-sm text-slate-600">${item.amount.toLocaleString()} ({item.percentage}%)</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div
className={`${item.color} h-2 rounded-full transition-all`}
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#0A39DF]" />
Budget Health Score
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4">
<div className={`w-24 h-24 mx-auto rounded-full flex items-center justify-center text-3xl font-bold ${trend === 'under' ? 'bg-green-100 text-green-700' : trend === 'on_track' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}>
{trend === 'under' ? 'A+' : trend === 'on_track' ? 'B+' : 'C'}
</div>
<p className="mt-3 font-semibold text-slate-900">
{trend === 'under' ? 'Excellent Budget Management' : trend === 'on_track' ? 'Good - Monitor Closely' : 'Action Required'}
</p>
<p className="text-sm text-slate-500 mt-1">
{trend === 'under'
? 'You have room for strategic investments'
: trend === 'on_track'
? 'Stay on track with current spending pace'
: 'Immediate cost reduction recommended'
}
</p>
</div>
<div className="grid grid-cols-3 gap-3 mt-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-green-600">12%</p>
<p className="text-[10px] text-slate-500">vs Last Period</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-blue-600">94%</p>
<p className="text-[10px] text-slate-500">Forecast Accuracy</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<p className="text-lg font-bold text-purple-600">$8.2K</p>
<p className="text-[10px] text-slate-500">Saved This Month</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick Decision Panel */}
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-purple-600 mt-0.5" />
<div className="flex-1">
<p className="font-semibold text-purple-900">Quick Decision for {config.title.split(' ')[0]}</p>
<p className="text-sm text-purple-700 mt-1">
{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."}
</p>
</div>
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700"
onClick={() => window.location.href = '/SavingsEngine'}
>
Execute <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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({
</div>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="visible">
<Droppable droppableId="visible" direction="vertical">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
{...provided.droppableProps}
className={`min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
snapshot.isDraggingOver
? 'border-blue-400 bg-blue-50'
: 'border-slate-200 bg-slate-50'
@@ -267,9 +273,13 @@ export default function DashboardCustomizer({
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`bg-white border-2 rounded-lg p-4 transition-all ${
style={getItemStyle(
snapshot.isDragging,
provided.draggableProps.style
)}
className={`bg-white border-2 rounded-lg p-4 mb-2 ${
snapshot.isDragging
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
? 'border-blue-400 shadow-2xl'
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
}`}
>

View File

@@ -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
<CardContent className="p-4 space-y-4">
<Label className="text-sm font-semibold">Event Details</Label>
{isVendor && (
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<Label className="text-xs font-semibold mb-2 flex items-center gap-2">
<Building2 className="w-4 h-4 text-amber-600" />
Select Client *
</Label>
<Select value={formData.business_id || ""} onValueChange={handleBusinessChange}>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="Choose client for this order" />
</SelectTrigger>
<SelectContent>
{businesses.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.business_name}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.business_id && (
<p className="text-xs text-amber-600 mt-2">
Client selected: {formData.business_name}
</p>
)}
</div>
)}
{isClient && (
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<Label className="text-xs font-semibold mb-2 flex items-center gap-2">
@@ -854,23 +1030,7 @@ export default function EventFormWizard({ event, onSubmit, onRapidSubmit, isSubm
/>
</div>
{isVendor && (
<div>
<Label className="text-sm mb-1.5 block">Client</Label>
<Select value={formData.business_id || ""} onValueChange={handleBusinessChange}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{businesses.map((business) => (
<SelectItem key={business.id} value={business.id}>
{business.business_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2 p-2.5 bg-green-50 rounded-lg">
<Checkbox

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,342 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Calendar, Copy, FileText, Search, Clock, Users, MapPin, Zap, Save, Star, Trash2 } from "lucide-react";
import { format, parseISO } from "date-fns";
export default function InvoiceQuickActions({
events = [],
invoices = [],
templates = [],
onImportFromEvent,
onDuplicateInvoice,
onUseTemplate,
onSaveTemplate,
onDeleteTemplate
}) {
const [eventDialogOpen, setEventDialogOpen] = useState(false);
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [templateName, setTemplateName] = useState("");
// Filter completed events that can be invoiced
const completedEvents = events.filter(e =>
e.status === "Completed" || e.status === "Active" || e.status === "Confirmed"
);
// Filter events by search
const filteredEvents = completedEvents.filter(e =>
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase())
);
// Filter invoices for duplication
const filteredInvoices = invoices.filter(inv =>
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
// Filter templates
const filteredTemplates = templates.filter(t =>
t.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.client_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleImportEvent = (event) => {
onImportFromEvent(event);
setEventDialogOpen(false);
setSearchTerm("");
};
const handleDuplicate = (invoice) => {
onDuplicateInvoice(invoice);
setDuplicateDialogOpen(false);
setSearchTerm("");
};
const handleUseTemplate = (template) => {
onUseTemplate(template);
setTemplateDialogOpen(false);
setSearchTerm("");
};
const handleSaveTemplate = () => {
if (templateName.trim()) {
onSaveTemplate(templateName.trim());
setSaveTemplateDialogOpen(false);
setTemplateName("");
}
};
return (
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-orange-100 rounded-xl border-2 border-amber-200">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="font-bold text-amber-900">Quick Actions</h3>
<p className="text-xs text-amber-700">Save time with these shortcuts</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{/* Import from Event */}
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Calendar className="w-4 h-4 mr-2" />
Import from Event
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-600" />
Import Staff Data from Event
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search events by name, client, or hub..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredEvents.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No completed events found</p>
</div>
) : (
filteredEvents.map(event => (
<button
key={event.id}
onClick={() => handleImportEvent(event)}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-all text-left"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-slate-900">{event.event_name}</h4>
<div className="flex items-center gap-3 mt-1 text-sm text-slate-600">
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{event.assigned_staff?.length || 0} staff
</span>
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{event.hub || event.event_location || "—"}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{event.date ? format(parseISO(event.date), 'MMM d, yyyy') : "—"}
</span>
</div>
<p className="text-xs text-slate-500 mt-1">{event.business_name}</p>
</div>
<Badge className={`${event.status === 'Completed' ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
{event.status}
</Badge>
</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Duplicate Invoice */}
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Copy className="w-4 h-4 mr-2" />
Duplicate Invoice
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5 text-purple-600" />
Duplicate an Existing Invoice
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by invoice #, client, or event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredInvoices.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No invoices found</p>
</div>
) : (
filteredInvoices.slice(0, 20).map(invoice => (
<button
key={invoice.id}
onClick={() => handleDuplicate(invoice)}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-purple-400 hover:bg-purple-50 transition-all text-left"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-bold text-slate-900">{invoice.invoice_number}</h4>
<Badge variant="outline" className="text-xs">${invoice.amount?.toLocaleString() || 0}</Badge>
</div>
<p className="text-sm text-slate-700 mt-1">{invoice.business_name}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
<span>{invoice.event_name || "No event"}</span>
<span></span>
<span>{invoice.roles?.[0]?.staff_entries?.length || 0} staff entries</span>
<span></span>
<span>{invoice.issue_date ? format(parseISO(invoice.issue_date), 'MMM d, yyyy') : "—"}</span>
</div>
</div>
<Badge className={`${invoice.status === 'Paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
{invoice.status}
</Badge>
</div>
</button>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Use Template */}
<Dialog open={templateDialogOpen} onOpenChange={setTemplateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<FileText className="w-4 h-4 mr-2" />
Use Template
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-emerald-600" />
Invoice Templates
</DialogTitle>
</DialogHeader>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search templates..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
{filteredTemplates.length === 0 ? (
<div className="text-center py-8 text-slate-500">
<Star className="w-12 h-12 mx-auto mb-2 text-slate-300" />
<p>No templates saved yet</p>
<p className="text-xs mt-1">Create an invoice and save it as a template</p>
</div>
) : (
filteredTemplates.map(template => (
<div
key={template.id}
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-emerald-400 hover:bg-emerald-50 transition-all"
>
<div className="flex items-start justify-between">
<button
onClick={() => handleUseTemplate(template)}
className="flex-1 text-left"
>
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
<h4 className="font-semibold text-slate-900">{template.name}</h4>
</div>
<p className="text-sm text-slate-600 mt-1">{template.client_name}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
<span>{template.staff_count || 0} staff entries</span>
<span></span>
<span>{template.charges_count || 0} charges</span>
</div>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => onDeleteTemplate(template.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))
)}
</div>
</DialogContent>
</Dialog>
{/* Save as Template */}
<Dialog open={saveTemplateDialogOpen} onOpenChange={setSaveTemplateDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
<Save className="w-4 h-4 mr-2" />
Save as Template
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Save className="w-5 h-5 text-blue-600" />
Save Current Invoice as Template
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium text-slate-700">Template Name</label>
<Input
placeholder="e.g., Google Weekly Catering, Standard Event Setup..."
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-slate-500 mt-1">
This will save client info, positions, rates, and charges as a reusable template
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setSaveTemplateDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSaveTemplate}
disabled={!templateName.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="w-4 h-4 mr-2" />
Save Template
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, TrendingUp, Users, Star } from "lucide-react";
import { Download, TrendingUp, Users, Star, Heart, AlertTriangle, ArrowUp } from "lucide-react";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
export default function ClientTrendsReport({ events, invoices }) {
export default function ClientTrendsReport({ events, invoices, userRole = 'admin' }) {
const { toast } = useToast();
// Bookings by month
@@ -75,65 +76,101 @@ export default function ClientTrendsReport({ events, invoices }) {
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
};
// Churn risk
const lowEngagementClients = topClients.filter(c => c.bookings < 3);
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="clients" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
<h2 className="text-xl font-bold text-slate-900">Client Intelligence</h2>
<p className="text-sm text-slate-500">Satisfaction, retention & growth opportunities</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
{/* Retention Alert */}
{lowEngagementClients.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm text-slate-500">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
<span className="text-sm font-medium text-red-800">{lowEngagementClients.length} clients at churn risk</span>
<p className="text-xs text-red-600">Less than 3 bookings this period</p>
</div>
</div>
<Button size="sm" variant="outline" className="border-red-300 text-red-700 hover:bg-red-100">
View List
</Button>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Heart className="w-5 h-5 text-green-600" />
<div>
<span className="text-sm font-medium text-green-800">{topClients.filter(c => c.bookings >= 5).length} loyal clients</span>
<p className="text-xs text-green-600">5+ bookings eligible for rewards</p>
</div>
</div>
<Button size="sm" variant="outline" className="border-green-300 text-green-700 hover:bg-green-100">
Send Thank You
</Button>
</div>
</div>
)}
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
<p className="text-xs text-slate-500 mt-1">Active this period</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Satisfaction</p>
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<Star className="w-6 h-6 text-amber-600" />
<Card className={`border-l-4 ${avgSatisfaction >= 4.5 ? 'border-l-green-500' : avgSatisfaction >= 4 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Satisfaction</p>
<div className="flex items-center gap-2">
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}</p>
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-3 h-3 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<p className={`text-xs mt-1 ${avgSatisfaction >= 4.5 ? 'text-green-600' : 'text-amber-600'}`}>
{avgSatisfaction >= 4.5 ? '✓ Excellent' : 'Room to improve'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className={`border-l-4 ${parseFloat(repeatRate) >= 40 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
<p className="text-xs text-slate-500 mt-1">${(topClients.reduce((s, c) => s + c.revenue, 0) * 0.4).toLocaleString()} from repeats</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{lowEngagementClients.length > 2
? `Re-engage ${lowEngagementClients.length} low-activity clients with special offers`
: parseFloat(repeatRate) < 40
? 'Launch loyalty program to boost repeat bookings by 25%'
: 'Top 3 clients ready for annual contract — lock in 12-month deals'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -1,14 +1,15 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
import { Download, Zap, Clock, TrendingUp, CheckCircle, Target, ArrowUp, ArrowDown } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
export default function OperationalEfficiencyReport({ events, staff }) {
export default function OperationalEfficiencyReport({ events, staff, userRole = 'admin' }) {
const { toast } = useToast();
// Automation impact metrics
@@ -78,72 +79,82 @@ export default function OperationalEfficiencyReport({ events, staff }) {
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="efficiency" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency</h2>
<p className="text-sm text-slate-500">Automation impact & process optimization</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Automation Rate</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-600" />
</div>
</div>
{/* ROI Highlight */}
<div className="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-purple-800">Automation ROI This Month</p>
<p className="text-2xl font-bold text-purple-900">$24,500 saved</p>
<p className="text-xs text-purple-600">Based on 85% automation rate × manual processing costs</p>
</div>
<div className="flex items-center gap-1 bg-green-100 px-3 py-1 rounded-full">
<ArrowUp className="w-4 h-4 text-green-600" />
<span className="text-sm font-semibold text-green-700">+18% vs last month</span>
</div>
</div>
</div>
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card className={`border-l-4 ${parseFloat(automationRate) >= 80 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Automation</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
<p className={`text-xs mt-1 ${parseFloat(automationRate) >= 80 ? 'text-green-600' : 'text-amber-600'}`}>
{parseFloat(automationRate) >= 80 ? '✓ Optimal' : `${(80 - parseFloat(automationRate)).toFixed(0)}% to target`}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
<Card className={`border-l-4 ${avgTimeToFill <= 2 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
<p className="text-xs text-slate-500 mt-1">Industry avg: 4.5h</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className={`border-l-4 ${avgResponseTime <= 1.5 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
<p className="text-xs text-green-600 mt-1">SLA: 2h </p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
</div>
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-emerald-600" />
</div>
</div>
<Card className="border-l-4 border-l-emerald-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
<p className="text-xs text-slate-500 mt-1">of {totalEvents} total</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{parseFloat(automationRate) < 80
? 'Enable auto-assignment for recurring orders (+15% automation)'
: avgTimeToFill > 2
? 'Expand preferred worker pool to reduce fill time'
: 'System performing well — document SOP for new sites'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,325 @@
import React, { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/components/ui/use-toast";
import {
Download, FileText, Table, FileSpreadsheet,
Printer, Share2, Link, CheckCircle, Loader2
} from "lucide-react";
import ReportPDFPreview from "./ReportPDFPreview";
const EXPORT_FORMATS = [
{ id: 'pdf', label: 'PDF Document', icon: FileText, description: 'Best for sharing and printing', color: 'red' },
{ id: 'excel', label: 'Excel Spreadsheet', icon: FileSpreadsheet, description: 'Editable with formulas', color: 'green' },
{ id: 'csv', label: 'CSV File', icon: Table, description: 'Universal data format', color: 'blue' },
{ id: 'json', label: 'JSON (API)', icon: Link, description: 'For system integration', color: 'purple' },
];
export default function ReportExporter({
open,
onClose,
reportName,
reportData,
onExport
}) {
const { toast } = useToast();
const [selectedFormat, setSelectedFormat] = useState('pdf');
const [isExporting, setIsExporting] = useState(false);
const [exportOptions, setExportOptions] = useState({
includeCharts: true,
includeSummary: true,
includeDetails: true,
includeFooter: true,
});
const handleExport = async () => {
setIsExporting(true);
try {
// Simulate export process
await new Promise(resolve => setTimeout(resolve, 1500));
// Generate filename
const timestamp = new Date().toISOString().split('T')[0];
const filename = `${reportName?.replace(/\s+/g, '-').toLowerCase() || 'report'}-${timestamp}`;
// Handle different export formats
let blob;
let extension;
switch (selectedFormat) {
case 'json':
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
extension = 'json';
break;
case 'csv':
// Simple CSV conversion
const csvContent = convertToCSV(reportData);
blob = new Blob([csvContent], { type: 'text/csv' });
extension = 'csv';
break;
case 'excel':
case 'pdf':
default:
// For demo, just export as JSON
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
extension = 'json';
toast({
title: "Note",
description: `${selectedFormat.toUpperCase()} export would be generated server-side. Downloaded as JSON for demo.`,
});
}
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.${extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "✅ Export Complete",
description: `${reportName} has been downloaded`,
});
onExport?.({ format: selectedFormat, filename });
onClose();
} catch (error) {
toast({
title: "Export Failed",
description: error.message,
variant: "destructive",
});
} finally {
setIsExporting(false);
}
};
const convertToCSV = (data) => {
if (!data || typeof data !== 'object') return '';
// Handle array of objects
if (Array.isArray(data) && data.length > 0) {
const headers = Object.keys(data[0]);
const rows = data.map(row =>
headers.map(h => JSON.stringify(row[h] ?? '')).join(',')
);
return [headers.join(','), ...rows].join('\n');
}
// Handle single object
return Object.entries(data)
.map(([key, value]) => `${key},${JSON.stringify(value)}`)
.join('\n');
};
const handlePrint = () => {
const printContent = document.getElementById('report-preview-content');
if (printContent) {
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<html>
<head>
<title>${reportName || 'Report'}</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; }
* { box-sizing: border-box; }
</style>
</head>
<body>${printContent.innerHTML}</body>
</html>
`);
printWindow.document.close();
printWindow.print();
} else {
window.print();
}
toast({ title: "Print dialog opened" });
};
const handleShare = async () => {
const shareUrl = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: reportName,
text: `Check out this ${reportName} report`,
url: shareUrl,
});
} catch (err) {
// User cancelled or error
}
} else {
await navigator.clipboard.writeText(shareUrl);
toast({ title: "Link Copied", description: "Report link copied to clipboard" });
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5 text-[#0A39DF]" />
Export Report
</DialogTitle>
</DialogHeader>
<Tabs value={selectedFormat} onValueChange={setSelectedFormat} className="mt-4">
<TabsList className="grid grid-cols-4 w-full">
{EXPORT_FORMATS.map(f => {
const Icon = f.icon;
return (
<TabsTrigger key={f.id} value={f.id} className="text-xs">
<Icon className="w-3 h-3 mr-1" />
{f.label.split(' ')[0]}
</TabsTrigger>
);
})}
</TabsList>
{/* PDF Preview */}
<TabsContent value="pdf" className="mt-4">
<div id="report-preview-content" className="max-h-[400px] overflow-y-auto border rounded-lg p-2 bg-slate-100">
<ReportPDFPreview
reportName={reportName}
reportData={reportData}
options={exportOptions}
/>
</div>
</TabsContent>
{/* Excel Preview */}
<TabsContent value="excel" className="mt-4">
<div className="border rounded-lg p-4 bg-green-50">
<div className="flex items-center gap-2 mb-3">
<FileSpreadsheet className="w-5 h-5 text-green-600" />
<span className="font-medium text-green-800">Excel Spreadsheet</span>
</div>
<div className="bg-white border rounded text-xs">
<div className="grid grid-cols-4 gap-px bg-slate-200">
<div className="bg-green-100 p-2 font-semibold">Category</div>
<div className="bg-green-100 p-2 font-semibold">Count</div>
<div className="bg-green-100 p-2 font-semibold">Amount</div>
<div className="bg-green-100 p-2 font-semibold">%</div>
{[
['Kitchen Staff', '45', '$42,500', '34%'],
['Event Servers', '38', '$35,200', '28%'],
['Bartenders', '28', '$28,800', '23%'],
].flat().map((cell, i) => (
<div key={i} className="bg-white p-2">{cell}</div>
))}
</div>
</div>
<p className="text-xs text-green-700 mt-2">Includes formulas, pivot tables, and multiple sheets</p>
</div>
</TabsContent>
{/* CSV Preview */}
<TabsContent value="csv" className="mt-4">
<div className="border rounded-lg p-4 bg-blue-50">
<div className="flex items-center gap-2 mb-3">
<Table className="w-5 h-5 text-blue-600" />
<span className="font-medium text-blue-800">CSV Data</span>
</div>
<pre className="bg-slate-900 text-green-400 p-3 rounded text-[10px] overflow-x-auto">
{`category,count,amount,percentage
"Kitchen Staff",45,42500,34
"Event Servers",38,35200,28
"Bartenders",28,28800,23
"Support Staff",22,18500,15`}
</pre>
<p className="text-xs text-blue-700 mt-2">Universal format for any spreadsheet or database</p>
</div>
</TabsContent>
{/* JSON Preview */}
<TabsContent value="json" className="mt-4">
<div className="border rounded-lg p-4 bg-purple-50">
<div className="flex items-center gap-2 mb-3">
<Link className="w-5 h-5 text-purple-600" />
<span className="font-medium text-purple-800">JSON (API)</span>
</div>
<pre className="bg-slate-900 text-amber-400 p-3 rounded text-[10px] overflow-x-auto max-h-32">
{`{
"report": "${reportName}",
"generated": "${new Date().toISOString()}",
"data": {
"totalSpend": 125000,
"fillRate": 94.2,
"categories": [...]
}
}`}
</pre>
<p className="text-xs text-purple-700 mt-2">For API push and system integration</p>
</div>
</TabsContent>
</Tabs>
{/* Export Options */}
<div className="flex items-center gap-4 mt-4 pt-4 border-t flex-wrap">
<div className="flex items-center gap-3 text-sm">
{[
{ key: 'includeSummary', label: 'Summary' },
{ key: 'includeCharts', label: 'Charts' },
{ key: 'includeDetails', label: 'Details' },
].map(opt => (
<label key={opt.key} className="flex items-center gap-1 cursor-pointer">
<Checkbox
checked={exportOptions[opt.key]}
onCheckedChange={(c) => setExportOptions({ ...exportOptions, [opt.key]: c })}
/>
<span className="text-xs text-slate-600">{opt.label}</span>
</label>
))}
</div>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="w-3 h-3 mr-1" />
Print
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="w-3 h-3 mr-1" />
Share
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={handleExport}
className="bg-[#0A39DF]"
disabled={isExporting}
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export {selectedFormat.toUpperCase()}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,178 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Clock, Brain, TrendingUp, Zap, Lightbulb, Target, AlertTriangle,
ArrowRight, CheckCircle, DollarSign, Users, Shield, Calendar
} from "lucide-react";
const ROLE_CONFIGS = {
procurement: {
color: 'from-blue-600 to-indigo-700',
title: 'Procurement Intelligence',
question: 'What should I negotiate this week?',
metrics: [
{ icon: Clock, label: 'Analysis Time', value: '12 hrs saved', trend: '+15%' },
{ icon: Brain, label: 'Decision Speed', value: '3x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Forecast Accuracy', value: '94%', trend: '+2.1%' },
],
actions: [
{ label: 'Renegotiate 3 vendor contracts expiring in 30 days', priority: 'high', impact: '$12,400 potential savings' },
{ label: 'Consolidate 5 underperforming vendors into Tier 1', priority: 'medium', impact: '18% rate reduction' },
],
prediction: 'Next quarter spend projected at $425K — 8% below budget if consolidation executed',
},
operator: {
color: 'from-emerald-600 to-teal-700',
title: 'Operations Command Center',
question: 'Where do I need to reallocate resources?',
metrics: [
{ icon: Clock, label: 'Report Time', value: '8 hrs saved', trend: '+22%' },
{ icon: Brain, label: 'Reallocation Speed', value: '5x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Demand Forecast', value: '91%', trend: '+3.5%' },
],
actions: [
{ label: 'Sector B understaffed by 12% — shift 8 workers from Sector A', priority: 'high', impact: 'Fill rate +15%' },
{ label: 'OT exposure at 22% in kitchen roles — redistribute shifts', priority: 'medium', impact: '$8,200 OT savings' },
],
prediction: 'Peak demand expected Dec 15-22 — pre-approve 45 additional workers now',
},
sector: {
color: 'from-purple-600 to-violet-700',
title: 'Sector Performance Hub',
question: 'Which sites need immediate attention?',
metrics: [
{ icon: Clock, label: 'Site Analysis', value: '6 hrs saved', trend: '+18%' },
{ icon: Brain, label: 'Shift Decisions', value: '4x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Pattern Accuracy', value: '89%', trend: '+4.2%' },
],
actions: [
{ label: 'Site #3 no-show rate spiked 8% — review worker pool', priority: 'high', impact: 'Reliability +12%' },
{ label: '3 certifications expiring this week — schedule renewals', priority: 'high', impact: 'Compliance 100%' },
],
prediction: 'Tuesday/Wednesday historically 15% understaffed — auto-schedule buffer',
},
client: {
color: 'from-green-600 to-emerald-700',
title: 'Your Staffing Intelligence',
question: 'How can I reduce costs without sacrificing quality?',
metrics: [
{ icon: Clock, label: 'Order Tracking', value: '4 hrs saved', trend: '+25%' },
{ icon: Brain, label: 'Vendor Choice', value: '2x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Cost Accuracy', value: '96%', trend: '+1.8%' },
],
actions: [
{ label: 'Switch 2 orders to Preferred Vendor — same quality, lower rate', priority: 'high', impact: '$2,100 savings' },
{ label: 'Lock in recurring staff for your top 3 positions', priority: 'medium', impact: '15% rate lock' },
],
prediction: 'Your Q1 staffing cost projected at $48K — $6K below last year',
},
vendor: {
color: 'from-amber-600 to-orange-700',
title: 'Vendor Growth Dashboard',
question: 'How do I maximize revenue and retention?',
metrics: [
{ icon: Clock, label: 'Scheduling Time', value: '10 hrs saved', trend: '+30%' },
{ icon: Brain, label: 'Assignment Match', value: '6x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Revenue Forecast', value: '92%', trend: '+2.5%' },
],
actions: [
{ label: '12 workers idle this week — propose to 3 pending orders', priority: 'high', impact: '$4,800 revenue' },
{ label: '2 clients at churn risk — schedule check-in calls', priority: 'high', impact: '$18K annual value' },
],
prediction: 'December revenue projected at $125K — 22% above November',
},
admin: {
color: 'from-slate-700 to-slate-900',
title: 'Platform Command Center',
question: 'What needs my attention across the entire system?',
metrics: [
{ icon: Clock, label: 'Oversight Time', value: '15 hrs saved', trend: '+35%' },
{ icon: Brain, label: 'System Decisions', value: '8x faster', trend: 'vs manual' },
{ icon: TrendingUp, label: 'Platform Accuracy', value: '97%', trend: '+1.2%' },
],
actions: [
{ label: '3 vendors pending compliance review — approve/reject today', priority: 'high', impact: 'Onboard $45K revenue' },
{ label: 'System automation at 85% — enable 2 more workflows', priority: 'medium', impact: '+$12K/month savings' },
],
prediction: 'Platform GMV projected at $2.4M this quarter — 18% growth',
},
};
export default function ReportInsightsBanner({ userRole = 'admin', reportType, data = {} }) {
const config = ROLE_CONFIGS[userRole] || ROLE_CONFIGS.admin;
return (
<div className={`bg-gradient-to-r ${config.color} rounded-xl text-white overflow-hidden`}>
{/* Header */}
<div className="p-4 border-b border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5" />
<span className="font-bold">{config.title}</span>
<Badge className="bg-white/20 text-white border-0 text-[10px]">AI-Powered</Badge>
</div>
<span className="text-sm opacity-80 italic">"{config.question}"</span>
</div>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-3 divide-x divide-white/10">
{config.metrics.map((metric, idx) => {
const Icon = metric.icon;
return (
<div key={idx} className="p-4 text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<Icon className="w-4 h-4 opacity-80" />
<span className="text-xs opacity-80">{metric.label}</span>
</div>
<p className="text-2xl font-bold">{metric.value}</p>
<p className="text-[11px] text-green-300">{metric.trend}</p>
</div>
);
})}
</div>
{/* Actions */}
<div className="p-4 bg-black/20">
<p className="text-xs font-semibold uppercase tracking-wider mb-2 opacity-80">
Recommended Actions
</p>
<div className="space-y-2">
{config.actions.map((action, idx) => (
<div key={idx} className="flex items-center justify-between bg-white/10 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
{action.priority === 'high' ? (
<AlertTriangle className="w-4 h-4 text-amber-300" />
) : (
<CheckCircle className="w-4 h-4 text-green-300" />
)}
<span className="text-sm">{action.label}</span>
</div>
<Badge className="bg-white/20 text-white border-0 text-[10px]">
{action.impact}
</Badge>
</div>
))}
</div>
</div>
{/* Prediction */}
<div className="p-3 bg-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="w-4 h-4" />
<span className="text-sm font-medium">Prediction:</span>
<span className="text-sm opacity-90">{config.prediction}</span>
</div>
<Button
size="sm"
variant="secondary"
className="h-7 text-xs bg-white text-slate-900 hover:bg-white/90"
onClick={() => window.location.href = '/SavingsEngine'}
>
Take Action <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { format } from "date-fns";
import { FileText, Building2, Calendar, User, TrendingUp, DollarSign, Users, CheckCircle } from "lucide-react";
export default function ReportPDFPreview({ reportName, reportData, options = {} }) {
const now = new Date();
const {
includeSummary = true,
includeCharts = true,
includeDetails = true,
includeFooter = true,
} = options;
// Sample metrics for preview
const metrics = {
totalSpend: reportData?.invoices?.reduce((s, i) => s + (i.amount || 0), 0) || 125000,
eventCount: reportData?.events?.length || 48,
staffCount: reportData?.staff?.length || 156,
vendorCount: reportData?.vendors?.length || 12,
fillRate: 94.2,
avgRate: 42.50,
};
return (
<div className="bg-white border-2 border-slate-200 rounded-lg shadow-lg max-w-2xl mx-auto" style={{ aspectRatio: '8.5/11' }}>
{/* PDF Page Container */}
<div className="p-6 h-full flex flex-col text-sm">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#1C323E] rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">K</span>
</div>
<div>
<h1 className="text-lg font-bold text-[#1C323E]">KROW</h1>
<p className="text-[10px] text-slate-500">Workforce Control Tower</p>
</div>
</div>
<div className="text-right text-[10px] text-slate-500">
<p>Generated: {format(now, 'MMM d, yyyy h:mm a')}</p>
<p>Report ID: RPT-{Math.random().toString(36).substr(2, 8).toUpperCase()}</p>
</div>
</div>
{/* Report Title */}
<div className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white p-4 rounded-lg mb-4">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-5 h-5" />
<h2 className="text-base font-bold">{reportName || 'Workforce Report'}</h2>
</div>
<p className="text-white/80 text-xs">
Period: {format(new Date(now.getFullYear(), now.getMonth(), 1), 'MMM d')} - {format(now, 'MMM d, yyyy')}
</p>
</div>
{/* Executive Summary */}
{includeSummary && (
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> Executive Summary
</h3>
<div className="grid grid-cols-3 gap-2">
<div className="bg-blue-50 p-2 rounded border border-blue-100">
<p className="text-[10px] text-blue-600 font-medium">Total Spend</p>
<p className="text-sm font-bold text-blue-700">${metrics.totalSpend.toLocaleString()}</p>
</div>
<div className="bg-emerald-50 p-2 rounded border border-emerald-100">
<p className="text-[10px] text-emerald-600 font-medium">Fill Rate</p>
<p className="text-sm font-bold text-emerald-700">{metrics.fillRate}%</p>
</div>
<div className="bg-purple-50 p-2 rounded border border-purple-100">
<p className="text-[10px] text-purple-600 font-medium">Avg Rate</p>
<p className="text-sm font-bold text-purple-700">${metrics.avgRate}/hr</p>
</div>
</div>
</div>
)}
{/* Chart Placeholder */}
{includeCharts && (
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<DollarSign className="w-3 h-3" /> Spend Breakdown
</h3>
<div className="bg-slate-50 border border-slate-200 rounded p-3 h-24 flex items-center justify-center">
<div className="flex items-end gap-1 h-16">
{[65, 45, 80, 55, 70, 40, 85].map((h, i) => (
<div
key={i}
className="w-6 bg-gradient-to-t from-[#0A39DF] to-blue-400 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
<div className="ml-4 text-[10px] text-slate-500">
<p>Weekly trend</p>
<p className="text-emerald-600"> 12% vs prior</p>
</div>
</div>
</div>
)}
{/* Data Table */}
{includeDetails && (
<div className="mb-4 flex-1">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
<Users className="w-3 h-3" /> Detailed Breakdown
</h3>
<div className="border border-slate-200 rounded overflow-hidden">
<table className="w-full text-[10px]">
<thead className="bg-slate-100">
<tr>
<th className="text-left p-1.5 font-semibold">Category</th>
<th className="text-right p-1.5 font-semibold">Count</th>
<th className="text-right p-1.5 font-semibold">Amount</th>
<th className="text-right p-1.5 font-semibold">%</th>
</tr>
</thead>
<tbody>
{[
{ cat: 'Kitchen Staff', count: 45, amount: 42500, pct: 34 },
{ cat: 'Event Servers', count: 38, amount: 35200, pct: 28 },
{ cat: 'Bartenders', count: 28, amount: 28800, pct: 23 },
{ cat: 'Support Staff', count: 22, amount: 18500, pct: 15 },
].map((row, i) => (
<tr key={i} className={i % 2 ? 'bg-slate-50' : ''}>
<td className="p-1.5">{row.cat}</td>
<td className="p-1.5 text-right">{row.count}</td>
<td className="p-1.5 text-right">${row.amount.toLocaleString()}</td>
<td className="p-1.5 text-right">{row.pct}%</td>
</tr>
))}
</tbody>
<tfoot className="bg-slate-100 font-semibold">
<tr>
<td className="p-1.5">Total</td>
<td className="p-1.5 text-right">133</td>
<td className="p-1.5 text-right">$125,000</td>
<td className="p-1.5 text-right">100%</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Key Insights */}
<div className="mb-4">
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2">Key Insights</h3>
<div className="space-y-1">
{[
'Fill rate improved 3.2% month-over-month',
'Kitchen staff utilization at 94% capacity',
'Top vendor: Premier Staffing (42% of orders)',
].map((insight, i) => (
<div key={i} className="flex items-center gap-1.5 text-[10px] text-slate-600">
<CheckCircle className="w-3 h-3 text-emerald-500 flex-shrink-0" />
{insight}
</div>
))}
</div>
</div>
{/* Footer */}
{includeFooter && (
<div className="mt-auto pt-3 border-t border-slate-200">
<div className="flex items-center justify-between text-[9px] text-slate-400">
<div className="flex items-center gap-2">
<Building2 className="w-3 h-3" />
<span>KROW Workforce Control Tower</span>
</div>
<span>Confidential - Internal Use Only</span>
<span>Page 1 of 1</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
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 { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
FileText, DollarSign, Users, TrendingUp, Shield, Clock,
Building2, Package, AlertTriangle, Star, Search, Filter,
Download, Eye, Zap, BarChart3, PieChart, MapPin, Calendar
} from "lucide-react";
const REPORT_TEMPLATES = [
// Procurement Reports
{ id: 'vendor-spend-analysis', name: 'Vendor Spend Analysis', category: 'Procurement', icon: DollarSign, color: 'blue', description: 'Detailed breakdown of spend by vendor, tier, and region', roles: ['procurement', 'admin'] },
{ id: 'vendor-performance-scorecard', name: 'Vendor Performance Scorecard', category: 'Procurement', icon: Star, color: 'amber', description: 'Fill rates, reliability scores, and SLA compliance by vendor', roles: ['procurement', 'admin'] },
{ id: 'rate-compliance', name: 'Rate Compliance Report', category: 'Procurement', icon: Shield, color: 'green', description: 'Contracted vs spot rate usage and savings opportunities', roles: ['procurement', 'admin'] },
{ id: 'vendor-consolidation', name: 'Vendor Consolidation Analysis', category: 'Procurement', icon: Package, color: 'purple', description: 'Opportunities to consolidate vendors for better rates', roles: ['procurement', 'admin'] },
// Operator Reports
{ id: 'enterprise-labor-summary', name: 'Enterprise Labor Summary', category: 'Operator', icon: Building2, color: 'indigo', description: 'Cross-sector labor costs, utilization, and trends', roles: ['operator', 'admin'] },
{ id: 'sector-comparison', name: 'Sector Comparison Report', category: 'Operator', icon: BarChart3, color: 'blue', description: 'Performance benchmarks across all sectors', roles: ['operator', 'admin'] },
{ id: 'overtime-exposure', name: 'Overtime Exposure Report', category: 'Operator', icon: Clock, color: 'red', description: 'OT hours by sector, site, and worker with cost impact', roles: ['operator', 'admin', 'sector'] },
{ id: 'fill-rate-analysis', name: 'Fill Rate Analysis', category: 'Operator', icon: TrendingUp, color: 'emerald', description: 'Order fulfillment rates and gap analysis', roles: ['operator', 'admin', 'sector'] },
// Sector Reports
{ id: 'site-labor-allocation', name: 'Site Labor Allocation', category: 'Sector', icon: MapPin, color: 'purple', description: 'Cost allocation by site, department, and cost center', roles: ['sector', 'operator', 'admin'] },
{ id: 'attendance-patterns', name: 'Attendance Patterns Report', category: 'Sector', icon: Calendar, color: 'blue', description: 'No-shows, late arrivals, and attendance trends', roles: ['sector', 'operator', 'admin'] },
{ id: 'worker-reliability', name: 'Worker Reliability Index', category: 'Sector', icon: Users, color: 'green', description: 'Individual worker performance and reliability scores', roles: ['sector', 'vendor', 'admin'] },
{ id: 'compliance-risk', name: 'Compliance Risk Report', category: 'Sector', icon: AlertTriangle, color: 'amber', description: 'Certification expirations, background check status', roles: ['sector', 'admin'] },
// Vendor Reports
{ id: 'client-revenue', name: 'Client Revenue Report', category: 'Vendor', icon: DollarSign, color: 'emerald', description: 'Revenue by client, event type, and time period', roles: ['vendor', 'admin'] },
{ id: 'workforce-utilization', name: 'Workforce Utilization', category: 'Vendor', icon: Users, color: 'blue', description: 'Staff hours, availability, and utilization rates', roles: ['vendor', 'admin'] },
{ id: 'margin-analysis', name: 'Margin Analysis Report', category: 'Vendor', icon: TrendingUp, color: 'purple', description: 'Profit margins by client, role, and event type', roles: ['vendor', 'admin'] },
{ id: 'staff-performance', name: 'Staff Performance Report', category: 'Vendor', icon: Star, color: 'amber', description: 'Ratings, feedback, and performance trends', roles: ['vendor', 'admin'] },
// Finance & Compliance
{ id: 'invoice-aging', name: 'Invoice Aging Report', category: 'Finance', icon: FileText, color: 'slate', description: 'Outstanding invoices by age and status', roles: ['admin', 'procurement', 'vendor'] },
{ id: 'payroll-summary', name: 'Payroll Summary Report', category: 'Finance', icon: DollarSign, color: 'green', description: 'Total labor costs, taxes, and deductions', roles: ['admin', 'vendor'] },
{ id: 'audit-trail', name: 'Audit Trail Report', category: 'Compliance', icon: Shield, color: 'indigo', description: 'All system changes and user activities', roles: ['admin'] },
{ id: 'certification-status', name: 'Certification Status Report', category: 'Compliance', icon: AlertTriangle, color: 'red', description: 'Expiring and missing certifications', roles: ['admin', 'sector', 'vendor'] },
// Client Reports
{ id: 'event-cost-summary', name: 'Event Cost Summary', category: 'Client', icon: Calendar, color: 'blue', description: 'Detailed costs for all events with breakdown', roles: ['client', 'admin'] },
{ id: 'savings-report', name: 'Savings Achieved Report', category: 'Client', icon: Zap, color: 'emerald', description: 'Cost savings from preferred vendor usage', roles: ['client', 'admin'] },
{ id: 'staff-feedback', name: 'Staff Feedback Report', category: 'Client', icon: Star, color: 'amber', description: 'Ratings and feedback on assigned staff', roles: ['client', 'admin'] },
];
const CATEGORY_COLORS = {
'Procurement': 'bg-blue-100 text-blue-700 border-blue-200',
'Operator': 'bg-indigo-100 text-indigo-700 border-indigo-200',
'Sector': 'bg-purple-100 text-purple-700 border-purple-200',
'Vendor': 'bg-amber-100 text-amber-700 border-amber-200',
'Finance': 'bg-emerald-100 text-emerald-700 border-emerald-200',
'Compliance': 'bg-red-100 text-red-700 border-red-200',
'Client': 'bg-green-100 text-green-700 border-green-200',
};
const ICON_COLORS = {
blue: 'bg-blue-500',
amber: 'bg-amber-500',
green: 'bg-green-500',
purple: 'bg-purple-500',
indigo: 'bg-indigo-500',
red: 'bg-red-500',
emerald: 'bg-emerald-500',
slate: 'bg-slate-500',
};
export default function ReportTemplateLibrary({ userRole, onSelectTemplate, onPreview }) {
const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const filteredTemplates = REPORT_TEMPLATES.filter(template => {
const matchesRole = template.roles.includes(userRole) || userRole === 'admin';
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = categoryFilter === "all" || template.category === categoryFilter;
return matchesRole && matchesSearch && matchesCategory;
});
const categories = [...new Set(REPORT_TEMPLATES.map(t => t.category))];
return (
<div className="space-y-5">
{/* Search & Filters - Clean Row */}
<div className="flex items-center gap-3 flex-wrap bg-white p-3 rounded-xl border">
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search reports..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 bg-slate-50 border-slate-200"
/>
</div>
<div className="flex gap-1.5 flex-wrap">
<Button
size="sm"
variant={categoryFilter === "all" ? "default" : "ghost"}
onClick={() => setCategoryFilter("all")}
className={`h-8 px-3 ${categoryFilter === "all" ? "bg-[#0A39DF]" : "text-slate-600"}`}
>
All
</Button>
{categories.map(cat => (
<Button
key={cat}
size="sm"
variant={categoryFilter === cat ? "default" : "ghost"}
onClick={() => setCategoryFilter(cat)}
className={`h-8 px-3 ${categoryFilter === cat ? "bg-[#0A39DF]" : "text-slate-600"}`}
>
{cat}
</Button>
))}
</div>
<Badge variant="outline" className="ml-auto text-slate-500 font-normal">
{filteredTemplates.length} reports
</Badge>
</div>
{/* Template Grid - Improved Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredTemplates.map(template => {
const Icon = template.icon;
return (
<div
key={template.id}
className="bg-white rounded-xl border border-slate-200 hover:border-blue-400 hover:shadow-lg transition-all cursor-pointer group overflow-hidden"
onClick={() => onSelectTemplate?.(template)}
>
<div className="p-4">
<div className="flex items-start gap-3 mb-3">
<div className={`w-10 h-10 ${ICON_COLORS[template.color]} rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm`}>
<Icon className="w-5 h-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-slate-900 group-hover:text-blue-700 leading-tight">
{template.name}
</h4>
<Badge className={`${CATEGORY_COLORS[template.category]} text-[10px] px-1.5 py-0 mt-1`}>
{template.category}
</Badge>
</div>
</div>
<p className="text-xs text-slate-500 line-clamp-2 mb-3 min-h-[32px]">{template.description}</p>
</div>
<div className="px-4 pb-4">
<Button
size="sm"
className="w-full h-9 bg-[#0A39DF] hover:bg-[#0831b8] font-medium"
onClick={(e) => { e.stopPropagation(); onSelectTemplate?.(template); }}
>
<Download className="w-4 h-4 mr-1.5" />
Generate Report
</Button>
</div>
</div>
);
})}
</div>
{filteredTemplates.length === 0 && (
<div className="text-center py-16 bg-white rounded-xl border">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="text-slate-500 font-medium">No reports match your search</p>
<p className="text-sm text-slate-400 mt-1">Try adjusting your filters</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,313 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import {
Clock, Calendar, Mail, Plus, Trash2, Edit2, Play, Pause,
Coffee, Sun, Moon, FileText, Download, Bell, Send
} from "lucide-react";
const SCHEDULE_OPTIONS = [
{ value: 'daily', label: 'Daily', icon: Sun, description: 'Every morning at 7 AM' },
{ value: 'weekly', label: 'Weekly', icon: Calendar, description: 'Every Monday morning' },
{ value: 'biweekly', label: 'Bi-Weekly', icon: Calendar, description: 'Every other Monday' },
{ value: 'monthly', label: 'Monthly', icon: Calendar, description: 'First of each month' },
{ value: 'quarterly', label: 'Quarterly', icon: Calendar, description: 'Start of each quarter' },
];
const TIME_OPTIONS = [
{ value: '06:00', label: '6:00 AM - Early Bird' },
{ value: '07:00', label: '7:00 AM - First Coffee ☕' },
{ value: '08:00', label: '8:00 AM - Start of Day' },
{ value: '09:00', label: '9:00 AM - Morning' },
{ value: '12:00', label: '12:00 PM - Midday' },
{ value: '17:00', label: '5:00 PM - End of Day' },
];
export default function ScheduledReports({ userRole, scheduledReports = [], onUpdate }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingReport, setEditingReport] = useState(null);
const [newSchedule, setNewSchedule] = useState({
report_name: '',
report_type: '',
frequency: 'weekly',
time: '07:00',
recipients: '',
format: 'pdf',
is_active: true,
});
const createScheduleMutation = useMutation({
mutationFn: async (scheduleData) => {
// In real app, this would create a scheduled job
// For now, we'll save it to user preferences
await base44.auth.updateMe({
scheduled_reports: [...(scheduledReports || []), {
...scheduleData,
id: Date.now().toString(),
created_at: new Date().toISOString()
}]
});
},
onSuccess: () => {
toast({ title: "✅ Schedule Created", description: "Your report will be delivered automatically" });
setShowCreateModal(false);
setNewSchedule({ report_name: '', report_type: '', frequency: 'weekly', time: '07:00', recipients: '', format: 'pdf', is_active: true });
onUpdate?.();
},
});
const toggleScheduleMutation = useMutation({
mutationFn: async ({ id, is_active }) => {
const updated = scheduledReports.map(r =>
r.id === id ? { ...r, is_active } : r
);
await base44.auth.updateMe({ scheduled_reports: updated });
},
onSuccess: () => {
toast({ title: "Schedule Updated" });
onUpdate?.();
},
});
const deleteScheduleMutation = useMutation({
mutationFn: async (id) => {
const updated = scheduledReports.filter(r => r.id !== id);
await base44.auth.updateMe({ scheduled_reports: updated });
},
onSuccess: () => {
toast({ title: "Schedule Deleted" });
onUpdate?.();
},
});
const handleCreate = () => {
if (!newSchedule.report_name || !newSchedule.recipients) {
toast({ title: "Missing Fields", description: "Please fill in all required fields", variant: "destructive" });
return;
}
createScheduleMutation.mutate(newSchedule);
};
const getFrequencyLabel = (freq) => {
const option = SCHEDULE_OPTIONS.find(o => o.value === freq);
return option?.label || freq;
};
const getNextDelivery = (schedule) => {
const now = new Date();
const [hours, minutes] = schedule.time.split(':').map(Number);
const next = new Date(now);
next.setHours(hours, minutes, 0, 0);
if (next <= now) {
switch (schedule.frequency) {
case 'daily':
next.setDate(next.getDate() + 1);
break;
case 'weekly':
next.setDate(next.getDate() + (7 - now.getDay() + 1) % 7 || 7);
break;
default:
next.setDate(next.getDate() + 1);
}
}
return next.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
};
return (
<div className="space-y-6">
{/* Compact Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Coffee className="w-5 h-5 text-[#0A39DF]" />
<span className="font-semibold text-slate-900">Scheduled Reports</span>
<span className="text-sm text-slate-500"> delivered like your morning coffee</span>
</div>
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-[#0A39DF]">
<Plus className="w-4 h-4 mr-1" />
Schedule
</Button>
</div>
{/* Compact Scheduled Reports List */}
{scheduledReports?.length > 0 ? (
<div className="space-y-2">
{scheduledReports.map(schedule => (
<div
key={schedule.id}
className={`flex items-center gap-4 p-3 rounded-lg border ${schedule.is_active ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100 opacity-60'}`}
>
<Switch
checked={schedule.is_active}
onCheckedChange={(checked) => toggleScheduleMutation.mutate({ id: schedule.id, is_active: checked })}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900 truncate">{schedule.report_name}</span>
<Badge className="bg-purple-100 text-purple-700 text-[10px]">{getFrequencyLabel(schedule.frequency)}</Badge>
<Badge variant="outline" className="text-[10px]">{schedule.format?.toUpperCase()}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span><Clock className="w-3 h-3 inline mr-1" />{schedule.time}</span>
<span className="truncate"><Mail className="w-3 h-3 inline mr-1" />{schedule.recipients}</span>
{schedule.is_active && <span className="text-emerald-600">Next: {getNextDelivery(schedule)}</span>}
</div>
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => { setEditingReport(schedule); setShowCreateModal(true); }}>
<Edit2 className="w-3 h-3" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-blue-600">
<Send className="w-3 h-3" />
</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-red-600" onClick={() => deleteScheduleMutation.mutate(schedule.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-slate-500">
<Clock className="w-8 h-8 mx-auto mb-2 text-slate-300" />
<p className="text-sm">No scheduled reports yet</p>
<Button onClick={() => setShowCreateModal(true)} size="sm" variant="link" className="text-[#0A39DF]">
Create your first schedule
</Button>
</div>
)}
{/* Create/Edit Modal */}
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#0A39DF]" />
{editingReport ? 'Edit Schedule' : 'Create Report Schedule'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Report Name *</label>
<Input
value={newSchedule.report_name}
onChange={(e) => setNewSchedule({ ...newSchedule, report_name: e.target.value })}
placeholder="e.g., Weekly Labor Summary"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Frequency</label>
<Select
value={newSchedule.frequency}
onValueChange={(v) => setNewSchedule({ ...newSchedule, frequency: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex items-center gap-2">
<opt.icon className="w-4 h-4" />
{opt.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Delivery Time</label>
<Select
value={newSchedule.time}
onValueChange={(v) => setNewSchedule({ ...newSchedule, time: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Recipients *</label>
<Input
value={newSchedule.recipients}
onChange={(e) => setNewSchedule({ ...newSchedule, recipients: e.target.value })}
placeholder="email@company.com, another@company.com"
/>
<p className="text-xs text-slate-500 mt-1">Separate multiple emails with commas</p>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Format</label>
<Select
value={newSchedule.format}
onValueChange={(v) => setNewSchedule({ ...newSchedule, format: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdf">📄 PDF Document</SelectItem>
<SelectItem value="excel">📊 Excel Spreadsheet</SelectItem>
<SelectItem value="csv">📋 CSV File</SelectItem>
</SelectContent>
</Select>
</div>
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
<p className="text-xs text-blue-800">
<Coffee className="w-3 h-3 inline mr-1" />
<strong>Pro Tip:</strong> 7 AM delivery ensures your report is ready with your morning coffee
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateModal(false)}>Cancel</Button>
<Button
onClick={handleCreate}
className="bg-[#0A39DF]"
disabled={createScheduleMutation.isPending}
>
{createScheduleMutation.isPending ? 'Creating...' : 'Create Schedule'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,14 +1,15 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Users, TrendingUp, Clock } from "lucide-react";
import { Download, Users, TrendingUp, Clock, AlertTriangle, Award } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
export default function StaffPerformanceReport({ staff, events }) {
export default function StaffPerformanceReport({ staff, events, userRole = 'admin' }) {
const { toast } = useToast();
// Calculate staff metrics
@@ -90,60 +91,79 @@ export default function StaffPerformanceReport({ staff, events }) {
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
};
// At-risk workers
const atRiskWorkers = staffMetrics.filter(s => s.reliability < 70 || s.noShows > 2);
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="performance" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
<h2 className="text-xl font-bold text-slate-900">Workforce Performance</h2>
<p className="text-sm text-slate-500">Reliability, fill rates & actionable insights</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
{/* Quick Actions */}
{atRiskWorkers.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<span className="text-sm font-medium text-amber-800">
{atRiskWorkers.length} workers need attention (reliability &lt;70% or 2+ no-shows)
</span>
</div>
<Button size="sm" variant="outline" className="border-amber-300 text-amber-700 hover:bg-amber-100">
View At-Risk Workers
</Button>
</div>
)}
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className={`border-l-4 ${avgReliability >= 85 ? 'border-l-green-500' : avgReliability >= 70 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
<p className={`text-xs mt-1 ${avgReliability >= 85 ? 'text-green-600' : 'text-amber-600'}`}>
{avgReliability >= 85 ? '✓ Excellent' : avgReliability >= 70 ? '⚠ Needs attention' : '✗ Critical'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
<Card className={`border-l-4 ${avgFillRate >= 90 ? 'border-l-green-500' : avgFillRate >= 75 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
<p className="text-xs text-slate-500 mt-1">{avgFillRate >= 90 ? 'Above target' : `${(90 - avgFillRate).toFixed(1)}% below target`}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-red-600" />
</div>
</div>
<Card className={`border-l-4 ${totalCancellations < 5 ? 'border-l-green-500' : totalCancellations < 15 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
<p className="text-xs text-red-600 mt-1">~${(totalCancellations * 150).toLocaleString()} impact</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{atRiskWorkers.length > 0
? `Review ${atRiskWorkers.length} at-risk workers before next scheduling cycle`
: avgFillRate < 90
? 'Expand worker pool by 15% to improve fill rate'
: 'Performance healthy — consider bonuses for top 10%'
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -1,15 +1,16 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
import { Download, DollarSign, TrendingUp, AlertCircle, Lightbulb, Clock, Brain, Target } from "lucide-react";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import ReportInsightsBanner from "./ReportInsightsBanner";
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
export default function StaffingCostReport({ events, invoices }) {
export default function StaffingCostReport({ events, invoices, userRole = 'admin' }) {
const [dateRange, setDateRange] = useState("30");
const { toast } = useToast();
@@ -92,10 +93,13 @@ export default function StaffingCostReport({ events, invoices }) {
return (
<div className="space-y-6">
{/* AI Insights Banner */}
<ReportInsightsBanner userRole={userRole} reportType="costs" />
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
<h2 className="text-xl font-bold text-slate-900">Labor Spend Analysis</h2>
<p className="text-sm text-slate-500">Track spending, budget compliance & cost optimization</p>
</div>
<div className="flex gap-2">
<Select value={dateRange} onValueChange={setDateRange}>
@@ -109,57 +113,52 @@ export default function StaffingCostReport({ events, invoices }) {
<SelectItem value="365">Last year</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-1" />
Export
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<DollarSign className="w-6 h-6 text-blue-600" />
</div>
</div>
{/* Decision Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-l-4 border-l-blue-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-slate-500 mt-1">This period</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
<Card className="border-l-4 border-l-green-500">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-green-600 mt-1">${(totalBudget - totalSpent).toLocaleString()} remaining</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget Adherence</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
</Badge>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-purple-600" />
</div>
</div>
<Card className={`border-l-4 ${adherence < 90 ? 'border-l-green-500' : adherence < 100 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
<CardContent className="pt-4 pb-3">
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget Used</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={`mt-1 ${adherence < 90 ? "bg-green-100 text-green-700" : adherence < 100 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>
{adherence < 90 ? "✓ Under Budget" : adherence < 100 ? "⚠ On Track" : "✗ Over Budget"}
</Badge>
</CardContent>
</Card>
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
<p className="text-sm font-medium text-purple-900 mt-1">
{adherence < 90
? "You have budget room — consider pre-booking Q1 staff at locked rates"
: adherence < 100
? "Monitor closely — reduce OT by 10% to stay on track"
: "Action needed — cut 15% discretionary spend immediately"
}
</p>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,422 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Target, ArrowRight, CheckCircle, AlertTriangle,
TrendingUp, Users, DollarSign, Zap, Star, Shield, Layers
} from "lucide-react";
import ConversionModal from "./ConversionModal";
export default function ContractConversionMap({ assignments, vendors, workforce, metrics, userRole }) {
const [selectedOpportunity, setSelectedOpportunity] = useState(null);
// Role-specific opportunities with fallback spend values
const baseSpend = metrics.totalSpend || 250000; // Default $250k if no data
const getProcurementOpportunities = () => [
{
id: 1,
category: "Vendor Tier Consolidation",
description: "Consolidate Tier 3 vendors into Tier 1 preferred network for better rates",
currentSpend: baseSpend * 0.25,
currentRate: metrics.avgNonContractedRate || 52,
targetRate: metrics.avgContractedRate || 42,
potentialSavings: (baseSpend * 0.25) * 0.19,
savingsPercent: 19,
priority: "high",
count: Math.max(Math.floor(vendors.length * 0.4), 5),
countLabel: "vendors",
recommendation: "Consolidate to Preferred Network",
benefits: ["19% rate reduction", "Stronger SLAs", "Simplified vendor management"],
tierFrom: "Standard Vendors",
tierTo: "Preferred Network",
},
{
id: 2,
category: "Multi-Vendor Spend Consolidation",
description: "Reduce vendor fragmentation by consolidating spend with top 3 partners",
currentSpend: baseSpend * 0.20,
currentRate: 48,
targetRate: 40,
potentialSavings: (baseSpend * 0.20) * 0.17,
savingsPercent: 17,
priority: "high",
count: 5,
countLabel: "vendors to consolidate",
recommendation: "Volume-based rate negotiation",
benefits: ["Volume discounts", "Single invoice processing", "Better compliance"],
tierFrom: "5+ Vendors",
tierTo: "2 Partners",
},
{
id: 3,
category: "Rate Card Optimization",
description: "Renegotiate rates with underperforming vendors or replace with preferred",
currentSpend: baseSpend * 0.15,
currentRate: 65,
targetRate: 55,
potentialSavings: (baseSpend * 0.15) * 0.15,
savingsPercent: 15,
priority: "medium",
count: Math.max(Math.floor(vendors.length * 0.2), 3),
countLabel: "vendors above market",
recommendation: "Rate renegotiation or replacement",
benefits: ["Market-aligned rates", "Performance guarantees", "Contract leverage"],
tierFrom: "Above Market",
tierTo: "Competitive Rates",
},
{
id: 4,
category: "Regional Vendor Optimization",
description: "Add regional preferred vendors to reduce travel costs and improve response",
currentSpend: baseSpend * 0.12,
currentRate: 50,
targetRate: 44,
potentialSavings: (baseSpend * 0.12) * 0.12,
savingsPercent: 12,
priority: "medium",
count: 3,
countLabel: "regions underserved",
recommendation: "Onboard regional vendors",
benefits: ["Local expertise", "Reduced travel fees", "Faster fulfillment"],
tierFrom: "National Only",
tierTo: "Regional Network",
},
];
const getClientOpportunities = () => [
{
id: 1,
category: "Staff Rate Optimization",
description: "Request staff through preferred vendors for lower hourly rates",
currentSpend: baseSpend * 0.25,
currentRate: metrics.avgNonContractedRate || 52,
targetRate: metrics.avgContractedRate || 42,
potentialSavings: (baseSpend * 0.25) * 0.19,
savingsPercent: 19,
priority: "high",
count: Math.max(Math.floor(workforce.length * 0.3), 5),
countLabel: "staff positions",
recommendation: "Use Preferred Vendor Network",
benefits: ["Lower hourly rates", "Same quality staff", "Better reliability"],
tierFrom: "Standard Rates",
tierTo: "Preferred Rates",
},
{
id: 2,
category: "Recurring Staff Savings",
description: "Convert recurring positions to contracted rates for consistent savings",
currentSpend: baseSpend * 0.20,
currentRate: 48,
targetRate: 40,
potentialSavings: (baseSpend * 0.20) * 0.17,
savingsPercent: 17,
priority: "high",
count: Math.max(Math.floor(workforce.length * 0.25), 4),
countLabel: "recurring positions",
recommendation: "Lock in contracted rates",
benefits: ["Predictable costs", "Priority staffing", "Dedicated workforce"],
tierFrom: "Spot Rates",
tierTo: "Contracted",
},
{
id: 3,
category: "Skill-Matched Staffing",
description: "Match staff skills to reduce overtime and improve productivity",
currentSpend: baseSpend * 0.15,
currentRate: 65,
targetRate: 55,
potentialSavings: (baseSpend * 0.15) * 0.15,
savingsPercent: 15,
priority: "medium",
count: Math.max(Math.floor(workforce.length * 0.2), 3),
countLabel: "positions",
recommendation: "Request skill-verified staff",
benefits: ["Less overtime", "Higher productivity", "Fewer replacements"],
tierFrom: "General Staff",
tierTo: "Skill-Matched",
},
{
id: 4,
category: "Favorite Staff Pool",
description: "Build a pool of preferred workers for consistent quality and rates",
currentSpend: baseSpend * 0.12,
currentRate: 50,
targetRate: 44,
potentialSavings: (baseSpend * 0.12) * 0.12,
savingsPercent: 12,
priority: "medium",
count: Math.max(Math.floor(workforce.length * 0.15), 3),
countLabel: "workers to add",
recommendation: "Create favorite staff list",
benefits: ["Familiar with your operations", "Faster onboarding", "Priority booking"],
tierFrom: "Random Assignment",
tierTo: "Preferred Workers",
},
];
const opportunities = userRole === "client" ? getClientOpportunities() : getProcurementOpportunities();
const priorityColors = {
high: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-red-100 text-red-700" },
medium: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-amber-100 text-amber-700" },
low: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-slate-100 text-slate-700" },
};
const totalPotentialSavings = opportunities.reduce((sum, o) => sum + o.potentialSavings, 0);
const totalCount = opportunities.reduce((sum, o) => sum + o.count, 0);
const countLabel = userRole === "client" ? "staff positions" : "vendors";
return (
<div className="space-y-6">
{/* Summary Banner */}
<Card className="bg-[#1C323E] text-white border-0">
<CardContent className="p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-2 mb-2">
<Layers className="w-6 h-6" />
<h3 className="text-xl font-bold">Rate & Vendor Optimization</h3>
</div>
<p className="text-slate-300">
{userRole === "client"
? `Optimize ${totalCount} staff positions for better rates`
: `Consolidate ${totalCount} ${countLabel} into preferred network`
}
</p>
<p className="text-sm text-slate-400 mt-1">
{userRole === "client"
? "Lower rates • Same quality staff • Better reliability"
: "Lower rates • Stronger partnerships • Simplified management"
}
</p>
</div>
<div className="text-right bg-white/10 rounded-xl p-4">
<p className="text-sm text-slate-300">Total Optimization Potential</p>
<p className="text-4xl font-bold">${totalPotentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-sm text-slate-300">annual savings</p>
</div>
</div>
</CardContent>
</Card>
{/* How Rate Optimization Works */}
<Card className="border border-slate-200">
<CardContent className="p-5">
<h4 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
<Zap className="w-5 h-5 text-[#0A39DF]" />
How Rate Optimization Works
</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">1</span>
</div>
<p className="text-sm font-medium text-slate-900">Identify Workers</p>
<p className="text-xs text-slate-500 mt-1">Workers with higher-tier vendors</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">2</span>
</div>
<p className="text-sm font-medium text-slate-900">Compare Rates</p>
<p className="text-xs text-slate-500 mt-1">Match to preferred vendor rates</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">3</span>
</div>
<p className="text-sm font-medium text-slate-900">Reassign</p>
<p className="text-xs text-slate-500 mt-1">Move to preferred vendors</p>
</div>
<div className="text-center p-3 bg-slate-50 rounded-lg">
<div className="w-10 h-10 bg-emerald-600 text-white rounded-full flex items-center justify-center mx-auto mb-2">
<span className="text-lg font-bold">4</span>
</div>
<p className="text-sm font-medium text-slate-900">Save</p>
<p className="text-xs text-slate-500 mt-1">Lower rates, same quality</p>
</div>
</div>
</CardContent>
</Card>
{/* Rate Tier Visualization */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-amber-300 bg-amber-50">
<CardContent className="p-5 text-center">
<div className="w-12 h-12 bg-amber-500 rounded-lg flex items-center justify-center mx-auto mb-3">
<DollarSign className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Standard Tier</h4>
<p className="text-3xl font-bold text-amber-600 mt-2">
${(metrics.avgNonContractedRate || 52).toFixed(0)}/hr
</p>
<p className="text-sm text-slate-500 mt-1">Higher rates Less volume</p>
</CardContent>
</Card>
<Card className="border-[#0A39DF] border-2 bg-blue-50">
<CardContent className="p-5 text-center relative">
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#0A39DF] text-white">
OPTIMIZE
</Badge>
<div className="w-12 h-12 bg-[#0A39DF] rounded-lg flex items-center justify-center mx-auto mb-3 mt-1">
<ArrowRight className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Ready to Optimize</h4>
<p className="text-3xl font-bold text-[#0A39DF] mt-2">
{totalCount} {countLabel}
</p>
<p className="text-sm text-slate-500 mt-1">Eligible for optimization</p>
</CardContent>
</Card>
<Card className="border-emerald-300 bg-emerald-50">
<CardContent className="p-5 text-center">
<div className="w-12 h-12 bg-emerald-600 rounded-lg flex items-center justify-center mx-auto mb-3">
<Shield className="w-6 h-6 text-white" />
</div>
<h4 className="font-semibold text-slate-900">Preferred Tier</h4>
<p className="text-3xl font-bold text-emerald-600 mt-2">
${(metrics.avgContractedRate || 42).toFixed(0)}/hr
</p>
<p className="text-sm text-slate-500 mt-1">Better rates Volume discounts</p>
</CardContent>
</Card>
</div>
{/* Optimization Opportunity Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{opportunities.map((opportunity) => {
const colors = priorityColors[opportunity.priority];
return (
<Card key={opportunity.id} className={`${colors.bg} ${colors.border} border-2 hover:shadow-lg transition-all`}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<Badge className={colors.badge}>
{opportunity.priority === "high" ? "HIGH IMPACT" : "MEDIUM IMPACT"}
</Badge>
<Badge variant="outline" className="bg-white">
{opportunity.tierFrom} {opportunity.tierTo}
</Badge>
</div>
<CardTitle className={`text-lg ${colors.text}`}>{opportunity.category}</CardTitle>
<p className="text-sm text-slate-500 mt-1">{opportunity.description}</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Rate Comparison */}
<div className="bg-white rounded-lg p-3 border">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700">Rate Comparison</span>
<Badge className="bg-green-100 text-green-700">
Save {opportunity.savingsPercent}%
</Badge>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<p className="text-xs text-slate-500">Current</p>
<p className="text-lg font-bold text-amber-600">${opportunity.currentRate}/hr</p>
</div>
<ArrowRight className="w-5 h-5 text-purple-500" />
<div className="flex-1">
<p className="text-xs text-slate-500">Optimized</p>
<p className="text-lg font-bold text-emerald-600">${opportunity.targetRate}/hr</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white/70 rounded-lg p-2">
<p className="text-slate-500 text-xs">Current Spend</p>
<p className="font-bold text-slate-900">${opportunity.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="bg-white/70 rounded-lg p-2">
<p className="text-slate-500 text-xs">Annual Savings</p>
<p className="font-bold text-green-600">${opportunity.potentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">{opportunity.count} {opportunity.countLabel} eligible</span>
</div>
<div className="flex flex-wrap gap-2">
{opportunity.benefits.map((benefit, idx) => (
<Badge key={idx} variant="outline" className="text-xs bg-white">
<CheckCircle className="w-3 h-3 mr-1 text-green-500" />
{benefit}
</Badge>
))}
</div>
<Button className="w-full bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => setSelectedOpportunity(opportunity)}>
<Zap className="w-4 h-4 mr-2" />
Optimize Now
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Vendor Recommendations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Star className="w-5 h-5 text-amber-500" />
Recommended Vendors for Conversion
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{vendors.slice(0, 3).map((vendor, idx) => (
<div key={vendor.id || idx} className="p-4 bg-slate-50 rounded-xl border border-slate-200">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-semibold text-slate-900">{vendor.legal_name || `Preferred Vendor ${idx + 1}`}</p>
<p className="text-xs text-slate-500">{vendor.region || "National"}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Avg Rate</span>
<span className="font-medium">${(metrics.avgContractedRate - idx * 2).toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Reliability</span>
<span className="font-medium text-green-600">{95 - idx}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Workforce</span>
<span className="font-medium">{vendor.workforce_count || 150 - idx * 20}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<ConversionModal
open={!!selectedOpportunity}
onClose={() => setSelectedOpportunity(null)}
opportunity={selectedOpportunity}
vendors={vendors}
userRole={userRole}
/>
</div>
);
}

View File

@@ -0,0 +1,690 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
import {
Target, Users, DollarSign, CheckCircle, ArrowRight,
Building2, Clock, Sparkles, AlertTriangle, Send, Shield,
FileText, MapPin, Phone, Mail, Star, XCircle, UserPlus,
Briefcase, CheckSquare, AlertCircle
} from "lucide-react";
export default function ConversionModal({ open, onClose, opportunity, vendors = [], userRole }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const [conversionType, setConversionType] = useState(""); // "existing" or "new"
const [selectedVendor, setSelectedVendor] = useState("");
const [selectedWorkers, setSelectedWorkers] = useState([]);
const [notes, setNotes] = useState("");
const [urgency, setUrgency] = useState("normal");
// New vendor referral fields
const [newVendor, setNewVendor] = useState({
legal_name: "",
contact_name: "",
email: "",
phone: "",
region: "",
services: "",
reason: "",
estimated_rate: "",
});
// For Clients: Mock workers for the opportunity
const affectedWorkers = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
id: `worker-${i + 1}`,
name: `Worker ${i + 1}`,
role: ["Server", "Bartender", "Cook", "Host", "Busser"][i % 5],
currentRate: 22 + Math.random() * 10,
assignments: Math.floor(Math.random() * 20) + 5,
}));
// For Procurement: Mock vendors to consolidate
const affectedVendors = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
id: `vendor-aff-${i + 1}`,
name: ["Bay Area Events", "Quick Staff Inc.", "Metro Workforce", "City Staffing Co.", "Premier Temps"][i % 5],
tier: i < 2 ? "Tier 1" : i < 4 ? "Tier 2" : "Tier 3",
currentSpend: 15000 + Math.random() * 20000,
rate: 45 + Math.random() * 15,
reliability: 75 + Math.random() * 20,
}));
const isProcurement = userRole === "procurement" || userRole === "admin" || userRole === "operator";
const createTaskMutation = useMutation({
mutationFn: (taskData) => base44.entities.Task.create(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
},
});
const createVendorInviteMutation = useMutation({
mutationFn: (inviteData) => base44.entities.VendorInvite.create(inviteData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendor-invites'] });
},
});
const handleClose = () => {
setStep(1);
setConversionType("");
setSelectedVendor("");
setSelectedWorkers([]);
setNotes("");
setUrgency("normal");
setNewVendor({
legal_name: "",
contact_name: "",
email: "",
phone: "",
region: "",
services: "",
reason: "",
estimated_rate: "",
});
onClose();
};
const handleSubmit = async () => {
if (conversionType === "new") {
// Create Vendor Referral Task for Procurement
const referralTask = {
task_name: `Vendor Referral: ${newVendor.legal_name}`,
team_id: "procurement",
description: `**New Vendor Referral Request**\n\n**Vendor Details:**\n- Company: ${newVendor.legal_name}\n- Contact: ${newVendor.contact_name}\n- Email: ${newVendor.email}\n- Phone: ${newVendor.phone}\n- Region: ${newVendor.region}\n\n**Services Needed:** ${newVendor.services}\n\n**Estimated Rate:** ${newVendor.estimated_rate}\n\n**Reason for Request:** ${newVendor.reason}\n\n**Conversion Opportunity:** ${opportunity?.category}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n**Workers to Convert:** ${selectedWorkers.length}\n\n**Urgency:** ${urgency}\n\n**Additional Notes:** ${notes}\n\n---\n**Action Required:**\n1. Verify vendor credentials\n2. Review insurance & compliance docs\n3. Evaluate rate alignment\n4. Approve/Reject for Preferred Network`,
status: "pending",
priority: urgency === "urgent" ? "high" : "normal",
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
};
// Also create vendor invite record
const vendorInvite = {
vendor_name: newVendor.legal_name,
contact_name: newVendor.contact_name,
email: newVendor.email,
phone: newVendor.phone,
status: "pending_review",
referral_reason: newVendor.reason,
services_requested: newVendor.services,
region: newVendor.region,
notes: `Referred via Conversion Engine. Category: ${opportunity?.category}. Potential Savings: $${opportunity?.potentialSavings?.toLocaleString()}`,
};
await Promise.all([
createTaskMutation.mutateAsync(referralTask),
createVendorInviteMutation.mutateAsync(vendorInvite),
]);
toast({
title: "Vendor Referral Submitted",
description: `${newVendor.legal_name} has been sent to Procurement for review and approval.`,
});
} else {
// Existing vendor conversion
const taskData = {
task_name: isProcurement
? `Vendor Consolidation: ${opportunity?.category}`
: `Rate Optimization: ${opportunity?.category}`,
team_id: "procurement",
description: isProcurement
? `**Vendor Consolidation Request**\n\n**Category:** ${opportunity?.category}\n**Target Tier 1 Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Vendors to Consolidate:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Review vendor spend analysis\n2. Negotiate volume discounts\n3. Migrate contracts to preferred vendor\n4. Track consolidated savings`
: `**Staff Rate Optimization**\n\n**Category:** ${opportunity?.category}\n**Target Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Positions Selected:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Confirm rate book assignment\n2. Update position assignments\n3. Track savings in dashboard`,
status: "pending",
priority: urgency === "urgent" ? "high" : "normal",
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
};
await createTaskMutation.mutateAsync(taskData);
toast({
title: isProcurement ? "Vendor Consolidation Submitted" : "Optimization Request Submitted",
description: isProcurement
? `${selectedWorkers.length} vendors will be consolidated to ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`
: `${selectedWorkers.length} positions will be optimized with ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`,
});
}
handleClose();
};
const toggleItem = (itemId) => {
setSelectedWorkers(prev =>
prev.includes(itemId)
? prev.filter(id => id !== itemId)
: [...prev, itemId]
);
};
const selectAllItems = () => {
const items = isProcurement ? affectedVendors : affectedWorkers;
if (selectedWorkers.length === items.length) {
setSelectedWorkers([]);
} else {
setSelectedWorkers(items.map(w => w.id));
}
};
if (!opportunity) return null;
const totalSteps = conversionType === "new" ? 4 : 3;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Target className="w-5 h-5 text-[#0A39DF]" />
{conversionType === "new" ? "New Vendor Referral" : "Start Conversion"}: {opportunity.category}
</DialogTitle>
</DialogHeader>
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((s) => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
step >= s ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'
}`}>
{step > s ? <CheckCircle className="w-4 h-4" /> : s}
</div>
{s < totalSteps && (
<div className={`flex-1 h-1 rounded ${step > s ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
)}
</React.Fragment>
))}
</div>
{/* Step 1: Choose Conversion Type */}
{step === 1 && (
<div className="space-y-4">
<div className="bg-gradient-to-r from-blue-50 to-purple-50 p-4 rounded-xl border border-blue-100">
<h4 className="font-semibold text-slate-900 mb-3">Conversion Opportunity Summary</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500">Current Spend</p>
<p className="text-lg font-bold text-slate-900">
${opportunity.currentSpend?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Potential Savings</p>
<p className="text-lg font-bold text-green-600">
${opportunity.potentialSavings?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
</div>
<div>
<p className="text-xs text-slate-500">{isProcurement ? "Vendors Affected" : "Positions Affected"}</p>
<p className="text-lg font-bold text-slate-900">{opportunity.count} {opportunity.countLabel}</p>
</div>
<div>
<p className="text-xs text-slate-500">Savings Rate</p>
<p className="text-lg font-bold text-amber-600">{opportunity.savingsPercent}%</p>
</div>
</div>
</div>
<h4 className="font-semibold text-slate-900">How would you like to convert?</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card
className={`cursor-pointer transition-all hover:shadow-md ${
conversionType === "existing" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
}`}
onClick={() => setConversionType("existing")}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-emerald-600" />
</div>
<div>
<h5 className="font-semibold text-slate-900">Preferred Vendor Network</h5>
<p className="text-sm text-slate-500 mt-1">
Convert to an already approved vendor in your network
</p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700">
<CheckCircle className="w-3 h-3 mr-1" />
Pre-Approved
</Badge>
</div>
</div>
</CardContent>
</Card>
<Card
className={`cursor-pointer transition-all hover:shadow-md ${
conversionType === "new" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
}`}
onClick={() => setConversionType("new")}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
<UserPlus className="w-5 h-5 text-amber-600" />
</div>
<div>
<h5 className="font-semibold text-slate-900">New Vendor Referral</h5>
<p className="text-sm text-slate-500 mt-1">
Refer a new vendor for procurement approval
</p>
<Badge className="mt-2 bg-amber-100 text-amber-700">
<AlertCircle className="w-3 h-3 mr-1" />
Requires Approval
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{conversionType === "new" && (
<div className="bg-amber-50 p-4 rounded-xl border border-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<p className="font-semibold text-amber-900">Vendor Referral Workflow</p>
<p className="text-sm text-amber-700 mt-1">
This vendor will be flagged as "Not Yet Approved" and sent to Procurement for review.
They'll evaluate pricing, insurance, compliance, and SLA capability before approval.
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Step 2 for New Vendor: Vendor Details */}
{step === 2 && conversionType === "new" && (
<div className="space-y-4">
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200 flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
<p className="text-sm text-blue-800">
<strong>Vendor Referral Form</strong> — This will be sent to Procurement for review
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Company Name *</label>
<Input
value={newVendor.legal_name}
onChange={(e) => setNewVendor({ ...newVendor, legal_name: e.target.value })}
placeholder="e.g., Bay Area Staffing Co."
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Contact Name *</label>
<Input
value={newVendor.contact_name}
onChange={(e) => setNewVendor({ ...newVendor, contact_name: e.target.value })}
placeholder="e.g., John Smith"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Email *</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.email}
onChange={(e) => setNewVendor({ ...newVendor, email: e.target.value })}
placeholder="vendor@company.com"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Phone</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.phone}
onChange={(e) => setNewVendor({ ...newVendor, phone: e.target.value })}
placeholder="(555) 123-4567"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Region / Location</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.region}
onChange={(e) => setNewVendor({ ...newVendor, region: e.target.value })}
placeholder="e.g., Bay Area, Los Angeles"
className="pl-10"
/>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Estimated Hourly Rate</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
value={newVendor.estimated_rate}
onChange={(e) => setNewVendor({ ...newVendor, estimated_rate: e.target.value })}
placeholder="e.g., $35-45/hr"
className="pl-10"
/>
</div>
</div>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Services Needed *</label>
<Textarea
value={newVendor.services}
onChange={(e) => setNewVendor({ ...newVendor, services: e.target.value })}
placeholder="e.g., Event catering staff, bartenders, servers for corporate events..."
className="h-20"
/>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-1 block">Reason for Request *</label>
<Textarea
value={newVendor.reason}
onChange={(e) => setNewVendor({ ...newVendor, reason: e.target.value })}
placeholder="Why do you need this vendor? e.g., Local specialist, unique skills, better coverage in specific area..."
className="h-20"
/>
</div>
</div>
)}
{/* Step 2 for Existing / Step 3 for New: Select Items */}
{((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && (
<div className="space-y-4">
{conversionType === "existing" && (
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
{isProcurement ? "Select Target Preferred Vendor" : "Select Preferred Vendor"}
</label>
<Select value={selectedVendor} onValueChange={setSelectedVendor}>
<SelectTrigger>
<SelectValue placeholder="Choose from approved vendors" />
</SelectTrigger>
<SelectContent>
{vendors.length > 0 ? vendors.filter(v => v.approval_status === "approved" || v.is_active).map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
{vendor.legal_name || vendor.doing_business_as}
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
)) : (
<>
<SelectItem value="vendor-1">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Premier Staffing Co.
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
<SelectItem value="vendor-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Elite Workforce Solutions
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
<SelectItem value="vendor-3">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
Compass Preferred Partners
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
</div>
</SelectItem>
</>
)}
</SelectContent>
</Select>
</div>
)}
{/* Procurement: Select Vendors to Consolidate */}
{isProcurement ? (
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
Select Vendors to Consolidate ({selectedWorkers.length}/{affectedVendors.length})
</label>
<Button variant="ghost" size="sm" onClick={selectAllItems}>
{selectedWorkers.length === affectedVendors.length ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{affectedVendors.map((vendor) => (
<div
key={vendor.id}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
selectedWorkers.includes(vendor.id) ? 'bg-blue-50' : ''
}`}
onClick={() => toggleItem(vendor.id)}
>
<Checkbox checked={selectedWorkers.includes(vendor.id)} />
<div className="flex-1">
<p className="font-medium text-sm">{vendor.name}</p>
<Badge className={`text-[10px] ${
vendor.tier === "Tier 1" ? "bg-emerald-100 text-emerald-700" :
vendor.tier === "Tier 2" ? "bg-amber-100 text-amber-700" :
"bg-red-100 text-red-700"
}`}>{vendor.tier}</Badge>
</div>
<div className="text-right">
<p className="text-sm font-medium">${vendor.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-xs text-slate-500">${vendor.rate.toFixed(0)}/hr avg</p>
</div>
</div>
))}
</div>
</div>
) : (
/* Client: Select Workers/Positions */
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-slate-700">
Select Positions to Optimize ({selectedWorkers.length}/{affectedWorkers.length})
</label>
<Button variant="ghost" size="sm" onClick={selectAllItems}>
{selectedWorkers.length === affectedWorkers.length ? "Deselect All" : "Select All"}
</Button>
</div>
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
{affectedWorkers.map((worker) => (
<div
key={worker.id}
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
selectedWorkers.includes(worker.id) ? 'bg-blue-50' : ''
}`}
onClick={() => toggleItem(worker.id)}
>
<Checkbox checked={selectedWorkers.includes(worker.id)} />
<div className="flex-1">
<p className="font-medium text-sm">{worker.name}</p>
<p className="text-xs text-slate-500">{worker.role}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">${worker.currentRate.toFixed(2)}/hr</p>
<p className="text-xs text-slate-500">{worker.assignments} shifts</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Final Step: Confirm & Submit */}
{((step === 3 && conversionType === "existing") || (step === 4 && conversionType === "new")) && (
<div className="space-y-4">
<div className={`p-4 rounded-xl border ${
conversionType === "new"
? 'bg-amber-50 border-amber-200'
: 'bg-emerald-50 border-emerald-200'
}`}>
<h4 className={`font-semibold mb-2 flex items-center gap-2 ${
conversionType === "new" ? 'text-amber-900' : 'text-emerald-900'
}`}>
{conversionType === "new" ? (
<>
<Send className="w-5 h-5" />
Vendor Referral Summary
</>
) : (
<>
<CheckCircle className="w-5 h-5" />
Conversion Request Summary
</>
)}
</h4>
<div className="space-y-2 text-sm">
<p><strong>Category:</strong> {opportunity.category}</p>
<p><strong>{isProcurement ? "Vendors" : "Positions"} Selected:</strong> {selectedWorkers.length}</p>
{conversionType === "new" ? (
<>
<p><strong>New Vendor:</strong> {newVendor.legal_name}</p>
<p><strong>Contact:</strong> {newVendor.contact_name} ({newVendor.email})</p>
<p><strong>Status:</strong> <Badge className="bg-amber-100 text-amber-700">Pending Procurement Review</Badge></p>
</>
) : (
<p><strong>Target Vendor:</strong> {vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}</p>
)}
<p><strong>Estimated Savings:</strong> <span className="text-green-600 font-bold">${Math.round((opportunity.potentialSavings || 0) * (selectedWorkers.length / Math.max(opportunity.count || 1, 1))).toLocaleString()} ({opportunity.savingsPercent || 0}%)</span></p>
</div>
</div>
{conversionType === "new" && (
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
<h5 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<Briefcase className="w-4 h-4" />
What Happens Next?
</h5>
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
<li>Vendor referral sent to Procurement team</li>
<li>Procurement evaluates pricing, insurance & compliance</li>
<li>Vendor approved → added to Preferred Network</li>
<li>Spot labor automatically converts to contracted savings</li>
</ol>
</div>
)}
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
Urgency Level
</label>
<Select value={urgency} onValueChange={setUrgency}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-slate-400" />
Low — Within 2 weeks
</div>
</SelectItem>
<SelectItem value="normal">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-500" />
Normal — Within 1 week
</div>
</SelectItem>
<SelectItem value="urgent">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-500" />
Urgent — Within 3 days
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium text-slate-700 mb-2 block">
Additional Notes (Optional)
</label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any special requirements or instructions..."
className="h-20"
/>
</div>
</div>
)}
<DialogFooter className="flex gap-2 mt-6">
{step > 1 && (
<Button variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{step === 1 ? (
<Button
onClick={() => setStep(2)}
className="bg-[#0A39DF] hover:bg-[#0831b8]"
disabled={!conversionType}
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
) : step < totalSteps ? (
<Button
onClick={() => setStep(step + 1)}
className="bg-[#0A39DF] hover:bg-[#0831b8]"
disabled={
(step === 2 && conversionType === "new" && (!newVendor.legal_name || !newVendor.email || !newVendor.services || !newVendor.reason)) ||
((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && selectedWorkers.length === 0
}
>
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
) : (
<Button
onClick={handleSubmit}
className={conversionType === "new" ? "bg-amber-600 hover:bg-amber-700" : "bg-emerald-600 hover:bg-emerald-700"}
disabled={createTaskMutation.isPending || createVendorInviteMutation.isPending}
>
<Send className="w-4 h-4 mr-2" />
{createTaskMutation.isPending || createVendorInviteMutation.isPending
? "Submitting..."
: conversionType === "new"
? "Submit Vendor Referral"
: "Submit Conversion Request"
}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,335 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, TrendingDown, DollarSign, Users, Target,
CheckCircle, AlertTriangle, Clock, BarChart3, PieChart,
ArrowUpRight, Zap, Shield, Star, Activity, Calendar,
Package, Award, MapPin, Building2
} from "lucide-react";
import { PieChart as RechartsPie, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
export default function DynamicSavingsDashboard({ metrics, projections, timeRange, userRole }) {
const getTimeLabel = () => {
switch (timeRange) {
case "7days": return "7-Day";
case "30days": return "30-Day";
case "quarter": return "Quarterly";
case "year": return "Annual";
default: return "30-Day";
}
};
const getProjectedSavings = () => {
switch (timeRange) {
case "7days": return projections.sevenDays;
case "30days": return projections.thirtyDays;
case "quarter": return projections.quarter;
case "year": return projections.year;
default: return projections.thirtyDays;
}
};
// Role-specific content
const getRoleContent = () => {
switch (userRole) {
case "procurement":
return {
mainTitle: "Vendor Network Performance",
mainSubtitle: "Optimize your preferred vendor network",
chartTitle: "Vendor Tier Distribution",
chartData: [
{ name: "Preferred Vendors", value: 40, color: "#22c55e" },
{ name: "Approved Vendors", value: 35, color: "#0A39DF" },
{ name: "Standard Vendors", value: 25, color: "#f59e0b" },
],
kpis: [
{ label: "Avg Vendor Score", current: `${(metrics.avgReliability + 5).toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability - 5).toFixed(0)}%`, trend: "up", icon: Award },
{ label: "Contract Compliance", current: `${metrics.contractedRatio.toFixed(0)}%`, optimized: "90%", savings: `+${(90 - metrics.contractedRatio).toFixed(0)}%`, trend: "up", icon: Shield },
{ label: "Rate Variance", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
{ label: "SLA Adherence", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: CheckCircle },
],
actions: [
{ priority: "high", action: "Upgrade 3 high-performing Standard vendors to Approved tier", impact: "Better rates, guaranteed capacity", deadline: "This week" },
{ priority: "high", action: "Renegotiate rates with top 5 Preferred vendors", impact: `$${(getProjectedSavings() * 0.3).toLocaleString()} potential savings`, deadline: "This month" },
{ priority: "medium", action: "Review vendors below 85% SLA adherence", impact: "Improve network reliability", deadline: "Next 2 weeks" },
],
};
case "operator":
return {
mainTitle: "Enterprise Operational Efficiency",
mainSubtitle: "Cross-sector performance optimization",
chartTitle: "Spend by Sector",
chartData: [
{ name: "Food Service", value: 45, color: "#0A39DF" },
{ name: "Events", value: 30, color: "#8b5cf6" },
{ name: "Facilities", value: 15, color: "#06b6d4" },
{ name: "Other", value: 10, color: "#f59e0b" },
],
kpis: [
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Labor Utilization", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
{ label: "Cost Efficiency", current: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`, optimized: `$${((metrics.totalSpend / Math.max(metrics.completedOrders, 1)) * 0.85).toFixed(0)}`, savings: "-15%", trend: "down", icon: DollarSign },
{ label: "Overtime Rate", current: "12%", optimized: "5%", savings: "-7%", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Balance workforce across underperforming sectors", impact: "Improve fill rate by 8%", deadline: "This week" },
{ priority: "high", action: "Implement predictive scheduling for peak periods", impact: "Reduce overtime by 40%", deadline: "Next 2 weeks" },
{ priority: "medium", action: "Cross-train staff for multi-sector flexibility", impact: "Increase utilization 15%", deadline: "This quarter" },
],
};
case "sector":
return {
mainTitle: "Location Performance",
mainSubtitle: "Your site's staffing efficiency",
chartTitle: "Position Coverage",
chartData: [
{ name: "Filled Positions", value: metrics.fillRate, color: "#22c55e" },
{ name: "Open Gaps", value: 100 - metrics.fillRate, color: "#ef4444" },
],
kpis: [
{ label: "Position Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Staff Attendance", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: CheckCircle },
{ label: "Shift Coverage", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Calendar },
{ label: "Response Time", current: "4 hrs", optimized: "1 hr", savings: "-3 hrs", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Fill 3 critical morning shift gaps", impact: "100% coverage for peak hours", deadline: "Tomorrow" },
{ priority: "high", action: "Request backup staff for weekend events", impact: "Prevent no-show impact", deadline: "This week" },
{ priority: "medium", action: "Train cross-functional backup team", impact: "Reduce gap response time", deadline: "This month" },
],
};
case "client":
return {
mainTitle: "Your Event Coverage & Savings",
mainSubtitle: "Maximize staffing quality while reducing costs",
chartTitle: "Event Fulfillment",
chartData: [
{ name: "Fully Staffed", value: metrics.fillRate, color: "#22c55e" },
{ name: "Partial Coverage", value: Math.max(0, 100 - metrics.fillRate - 5), color: "#f59e0b" },
{ name: "Gaps", value: Math.min(5, 100 - metrics.fillRate), color: "#ef4444" },
],
kpis: [
{ label: "Event Coverage", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Calendar },
{ label: "Staff Quality", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Star },
{ label: "Cost Savings", current: `$${metrics.avgNonContractedRate.toFixed(0)}/hr`, optimized: `$${metrics.avgContractedRate.toFixed(0)}/hr`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(0)}/hr`, trend: "down", icon: DollarSign },
{ label: "On-Time Arrival", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: Clock },
],
actions: [
{ priority: "high", action: "Book preferred vendors for upcoming events", impact: `Save $${(getProjectedSavings() * 0.2).toLocaleString()} on rates`, deadline: "Before next event" },
{ priority: "medium", action: "Request top-rated staff from past events", impact: "Improve service quality 20%", deadline: "Next booking" },
{ priority: "medium", action: "Bundle multiple events for volume discount", impact: "Additional 10% savings", deadline: "This month" },
],
};
case "vendor":
return {
mainTitle: "Your Competitive Performance",
mainSubtitle: "Stand out in the vendor network",
chartTitle: "Your Performance vs. Network",
chartData: [
{ name: "Your Score", value: metrics.avgReliability, color: "#0A39DF" },
{ name: "Network Avg", value: 100 - metrics.avgReliability, color: "#e2e8f0" },
],
kpis: [
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "99%", savings: `+${(99 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
{ label: "Team Reliability", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
{ label: "Client Value", current: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`, optimized: `$${((metrics.avgNonContractedRate - metrics.avgContractedRate) * 1.2).toFixed(2)}/hr`, savings: "+20%", trend: "up", icon: Zap },
{ label: "Response Time", current: "2 hrs", optimized: "30 min", savings: "-1.5 hrs", trend: "down", icon: Clock },
],
actions: [
{ priority: "high", action: "Improve response time to order requests", impact: "Win 15% more orders", deadline: "Immediate" },
{ priority: "high", action: "Reduce no-show rate below 2%", impact: "Upgrade to Preferred tier", deadline: "This month" },
{ priority: "medium", action: "Expand certified workforce pool", impact: "Access premium assignments", deadline: "This quarter" },
],
};
default: // admin
return {
mainTitle: "Platform-Wide Savings",
mainSubtitle: "Transform labor spend into strategic advantage",
chartTitle: "Labor Spend Mix",
chartData: [
{ name: "Contracted Labor", value: metrics.contractedSpend, color: "#22c55e" },
{ name: "Non-Contracted", value: metrics.nonContractedSpend, color: "#ef4444" },
],
kpis: [
{ label: "Cost per Hour", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(1)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(1)}%`, trend: "up", icon: Target },
{ label: "No-Show Rate", current: `${metrics.noShowRate.toFixed(1)}%`, optimized: "1%", savings: `-${(metrics.noShowRate - 1).toFixed(1)}%`, trend: "down", icon: AlertTriangle },
{ label: "Reliability Score", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Shield },
],
actions: [
{ priority: "high", action: "Convert 15 high-volume gig workers to preferred vendor", impact: `$${(getProjectedSavings() * 0.25).toLocaleString()} savings`, deadline: "This week" },
{ priority: "high", action: "Negotiate volume discount with top 3 vendors", impact: `$${(getProjectedSavings() * 0.2).toLocaleString()} savings`, deadline: "Next 2 weeks" },
{ priority: "medium", action: "Reduce overtime through better scheduling", impact: `$${(getProjectedSavings() * 0.15).toLocaleString()} savings`, deadline: "This month" },
],
};
}
};
const content = getRoleContent();
return (
<div className="space-y-6">
{/* Main Summary */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white border-0">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<p className="text-blue-200 text-sm font-medium mb-1">{content.mainTitle}</p>
<p className="text-4xl font-bold">${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
<p className="text-blue-200 mt-2">{content.mainSubtitle}</p>
</div>
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
<TrendingUp className="w-8 h-8 text-white" />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{content.kpis.slice(0, 4).map((kpi, idx) => (
<div key={idx} className="bg-white/10 rounded-lg p-3">
<p className="text-blue-200 text-xs">{kpi.label}</p>
<p className="text-xl font-bold">{kpi.current}</p>
<p className="text-xs text-green-300">{kpi.savings}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<PieChart className="w-5 h-5 text-slate-400" />
{content.chartTitle}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<RechartsPie>
<Pie
data={content.chartData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
paddingAngle={5}
dataKey="value"
>
{content.chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(value) => typeof value === 'number' && value > 100 ? `$${value.toLocaleString()}` : `${value}%`} />
</RechartsPie>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap justify-center gap-3 mt-2">
{content.chartData.map((item, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-slate-600">{item.name}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* KPI Comparison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-500" />
Performance Optimization Metrics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{content.kpis.map((kpi, idx) => {
const Icon = kpi.icon;
return (
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
<div className="flex items-center gap-2 mb-3">
<Icon className="w-5 h-5 text-slate-400" />
<span className="font-medium text-slate-700">{kpi.label}</span>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Current</span>
<span className="font-semibold text-slate-900">{kpi.current}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Target</span>
<span className="font-semibold text-green-600">{kpi.optimized}</span>
</div>
<div className="pt-2 border-t border-slate-200">
<div className="flex justify-between items-center">
<span className="text-sm text-slate-500">Impact</span>
<Badge className={kpi.trend === "up" ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700"}>
{kpi.trend === "up" ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
{kpi.savings}
</Badge>
</div>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Action Items */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-500" />
Priority Actions for You
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{content.actions.map((item, idx) => (
<div
key={idx}
className={`p-4 rounded-xl border-2 flex items-center justify-between ${
item.priority === "high"
? "bg-red-50 border-red-200"
: "bg-amber-50 border-amber-200"
}`}
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${item.priority === "high" ? "bg-red-500" : "bg-amber-500"}`} />
<div>
<p className="font-medium text-slate-900">{item.action}</p>
<div className="flex items-center gap-3 mt-1 text-sm text-slate-500">
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
{item.impact}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{item.deadline}
</span>
</div>
</div>
</div>
<Badge className={item.priority === "high" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
{item.priority.toUpperCase()}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,272 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Users, DollarSign, Clock, TrendingUp, TrendingDown,
Building2, MapPin, Briefcase, AlertTriangle, CheckCircle
} from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
export default function LaborSpendAnalysis({ assignments, workforce, orders, metrics, userRole }) {
// Generate channel breakdown
const channelData = [
{ channel: "Preferred Vendors", spend: metrics.contractedSpend * 0.6, workers: Math.floor(workforce.length * 0.4), efficiency: 94 },
{ channel: "Approved Vendors", spend: metrics.contractedSpend * 0.4, workers: Math.floor(workforce.length * 0.25), efficiency: 88 },
{ channel: "Gig Platforms", spend: metrics.nonContractedSpend * 0.5, workers: Math.floor(workforce.length * 0.2), efficiency: 72 },
{ channel: "Agency Labor", spend: metrics.nonContractedSpend * 0.3, workers: Math.floor(workforce.length * 0.1), efficiency: 68 },
{ channel: "Internal Pool", spend: metrics.nonContractedSpend * 0.2, workers: Math.floor(workforce.length * 0.05), efficiency: 91 },
];
const utilizationData = [
{ category: "Culinary Staff", utilized: 85, available: 100, cost: 45000 },
{ category: "Event Staff", utilized: 78, available: 100, cost: 32000 },
{ category: "Bartenders", utilized: 92, available: 100, cost: 28000 },
{ category: "Security", utilized: 65, available: 100, cost: 18000 },
{ category: "Facilities", utilized: 71, available: 100, cost: 15000 },
];
const benchmarkData = [
{ metric: "Cost per Hour", yours: metrics.avgNonContractedRate, benchmark: 42.50, industry: 48.00 },
{ metric: "Fill Rate", yours: metrics.fillRate, benchmark: 95, industry: 88 },
{ metric: "No-Show Rate", yours: metrics.noShowRate, benchmark: 2, industry: 5 },
{ metric: "OT Percentage", yours: 12, benchmark: 8, industry: 15 },
];
return (
<div className="space-y-6">
{/* Channel Breakdown */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-blue-500" />
Labor Spend by Channel
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Channel</TableHead>
<TableHead className="text-right">Spend</TableHead>
<TableHead className="text-right">Workers</TableHead>
<TableHead className="text-right">Efficiency</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channelData.map((row, idx) => (
<TableRow key={idx}>
<TableCell className="font-medium">{row.channel}</TableCell>
<TableCell className="text-right font-semibold">
${row.spend.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</TableCell>
<TableCell className="text-right">{row.workers}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Progress value={row.efficiency} className="w-16 h-2" />
<span className="text-sm font-medium">{row.efficiency}%</span>
</div>
</TableCell>
<TableCell>
<Badge className={row.efficiency >= 85 ? "bg-green-100 text-green-700" : row.efficiency >= 70 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}>
{row.efficiency >= 85 ? "Optimal" : row.efficiency >= 70 ? "Review" : "Optimize"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Utilization Chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-purple-500" />
Workforce Utilization by Category
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={utilizationData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis type="number" domain={[0, 100]} stroke="#64748b" fontSize={12} />
<YAxis type="category" dataKey="category" stroke="#64748b" fontSize={12} width={100} />
<Tooltip
formatter={(value) => [`${value}%`, 'Utilization']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Bar dataKey="utilized" fill="#0A39DF" radius={[0, 4, 4, 0]} name="Utilized" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-500" />
Cost by Category
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{utilizationData.map((item, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="font-medium text-slate-900">{item.category}</p>
<p className="text-sm text-slate-500">{item.utilized}% utilized</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-slate-900">${item.cost.toLocaleString()}</p>
<p className="text-xs text-slate-500">monthly spend</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Benchmarking */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-amber-500" />
Performance Benchmarking
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{benchmarkData.map((item, idx) => {
const isBetter = item.metric === "No-Show Rate" || item.metric === "OT Percentage"
? item.yours < item.benchmark
: item.yours > item.benchmark;
return (
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-500 mb-2">{item.metric}</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Your Rate</span>
<span className={`font-bold ${isBetter ? "text-green-600" : "text-red-600"}`}>
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.yours.toFixed(1)}%`
: `$${item.yours.toFixed(2)}`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-600">Benchmark</span>
<span className="font-medium text-slate-900">
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.benchmark}%`
: `$${item.benchmark.toFixed(2)}`}
</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-200">
<span className="text-sm text-slate-600">Industry Avg</span>
<span className="text-slate-500">
{item.metric.includes("Rate") || item.metric.includes("Percentage")
? `${item.industry}%`
: `$${item.industry.toFixed(2)}`}
</span>
</div>
</div>
<div className="mt-3">
<Badge className={isBetter ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}>
{isBetter ? <CheckCircle className="w-3 h-3 mr-1" /> : <AlertTriangle className="w-3 h-3 mr-1" />}
{isBetter ? "Above Benchmark" : "Below Benchmark"}
</Badge>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Contracted vs Non-Contracted Comparison */}
<Card>
<CardHeader>
<CardTitle>Contracted vs. Non-Contracted Labor Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-6 bg-green-50 rounded-xl border-2 border-green-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-bold text-green-900">Contracted Labor</h4>
<p className="text-sm text-green-700">{metrics.contractedRatio.toFixed(1)}% of total spend</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-green-700">Total Spend</span>
<span className="font-bold text-green-900">${metrics.contractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Avg Rate</span>
<span className="font-bold text-green-900">${metrics.avgContractedRate.toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Reliability</span>
<span className="font-bold text-green-900">92%</span>
</div>
<div className="flex justify-between">
<span className="text-green-700">Fill Rate</span>
<span className="font-bold text-green-900">96%</span>
</div>
</div>
</div>
<div className="p-6 bg-red-50 rounded-xl border-2 border-red-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-bold text-red-900">Non-Contracted Labor</h4>
<p className="text-sm text-red-700">{(100 - metrics.contractedRatio).toFixed(1)}% of total spend</p>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-red-700">Total Spend</span>
<span className="font-bold text-red-900">${metrics.nonContractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Avg Rate</span>
<span className="font-bold text-red-900">${metrics.avgNonContractedRate.toFixed(2)}/hr</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Reliability</span>
<span className="font-bold text-red-900">71%</span>
</div>
<div className="flex justify-between">
<span className="text-red-700">Fill Rate</span>
<span className="font-bold text-red-900">78%</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, DollarSign, Calendar, Target,
ArrowRight, CheckCircle, Sparkles, BarChart3,
Clock, Users, Zap, ArrowUpRight
} from "lucide-react";
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Legend } from "recharts";
export default function PredictiveSavingsModel({ metrics, projections, assignments, rates, userRole }) {
// Generate forecast data
const forecastData = [
{ month: "Jan", current: 45000, optimized: 38000, savings: 7000 },
{ month: "Feb", current: 48000, optimized: 40000, savings: 8000 },
{ month: "Mar", current: 52000, optimized: 42000, savings: 10000 },
{ month: "Apr", current: 50000, optimized: 40000, savings: 10000 },
{ month: "May", current: 55000, optimized: 43000, savings: 12000 },
{ month: "Jun", current: 58000, optimized: 45000, savings: 13000 },
{ month: "Jul", current: 62000, optimized: 47000, savings: 15000 },
{ month: "Aug", current: 60000, optimized: 46000, savings: 14000 },
{ month: "Sep", current: 55000, optimized: 43000, savings: 12000 },
{ month: "Oct", current: 58000, optimized: 45000, savings: 13000 },
{ month: "Nov", current: 65000, optimized: 50000, savings: 15000 },
{ month: "Dec", current: 70000, optimized: 53000, savings: 17000 },
];
const savingsStrategies = [
{
id: 1,
strategy: "Shift to Higher-Performing Vendors",
impact: "High",
savingsPercent: 12,
timeToImplement: "2-4 weeks",
confidence: 92,
description: "Consolidate spend with top-tier vendors who offer better rates and reliability",
},
{
id: 2,
strategy: "Contracted Pricing Negotiation",
impact: "High",
savingsPercent: 15,
timeToImplement: "4-6 weeks",
confidence: 88,
description: "Lock in volume discounts with preferred supplier agreements",
},
{
id: 3,
strategy: "Internal Workforce Pool",
impact: "Medium",
savingsPercent: 8,
timeToImplement: "6-8 weeks",
confidence: 85,
description: "Build internal pool for recurring needs, reducing agency fees",
},
{
id: 4,
strategy: "Skill-Matched Talent",
impact: "Medium",
savingsPercent: 6,
timeToImplement: "2-3 weeks",
confidence: 90,
description: "Match worker skills to job requirements, reducing overtime and rework",
},
];
const scenarioData = [
{ scenario: "Conservative", savings: projections.year * 0.7 },
{ scenario: "Moderate", savings: projections.year },
{ scenario: "Aggressive", savings: projections.year * 1.4 },
];
return (
<div className="space-y-6">
{/* Predictive Header */}
<Card className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white border-0">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold">AI-Powered Savings Forecast</h3>
<p className="text-purple-200">Predictive analysis based on your workforce data</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
{[
{ label: "7-Day Forecast", value: projections.sevenDays },
{ label: "30-Day Forecast", value: projections.thirtyDays },
{ label: "Quarterly Forecast", value: projections.quarter },
{ label: "Annual Forecast", value: projections.year },
].map((item, idx) => (
<div key={idx} className="bg-white/10 rounded-lg p-4">
<p className="text-purple-200 text-sm">{item.label}</p>
<p className="text-2xl font-bold">${item.value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Forecast Chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-500" />
12-Month Savings Projection
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={forecastData}>
<defs>
<linearGradient id="colorCurrent" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0}/>
</linearGradient>
<linearGradient id="colorOptimized" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
<Tooltip
formatter={(value) => [`$${value.toLocaleString()}`, '']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Legend />
<Area type="monotone" dataKey="current" stroke="#ef4444" fill="url(#colorCurrent)" name="Current Spend" />
<Area type="monotone" dataKey="optimized" stroke="#22c55e" fill="url(#colorOptimized)" name="Optimized Spend" />
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-blue-500" />
Monthly Savings Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={forecastData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
<Tooltip
formatter={(value) => [`$${value.toLocaleString()}`, 'Savings']}
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
/>
<Bar dataKey="savings" fill="#0A39DF" radius={[4, 4, 0, 0]} name="Savings" />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
{/* Savings Strategies */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="w-5 h-5 text-amber-500" />
Recommended Savings Strategies
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{savingsStrategies.map((strategy) => (
<div key={strategy.id} className="p-4 bg-slate-50 rounded-xl border border-slate-200 hover:shadow-md transition-all">
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-slate-900">{strategy.strategy}</h4>
<Badge className={strategy.impact === "High" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
{strategy.impact} Impact
</Badge>
</div>
<p className="text-sm text-slate-600">{strategy.description}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-green-600">+{strategy.savingsPercent}%</p>
<p className="text-xs text-slate-500">potential savings</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-slate-500">
<Clock className="w-4 h-4" />
{strategy.timeToImplement}
</div>
<div className="flex items-center gap-1 text-slate-500">
<CheckCircle className="w-4 h-4 text-green-500" />
{strategy.confidence}% confidence
</div>
</div>
<Button size="sm" className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => alert(`Implementing: ${strategy.strategy}`)}>
Implement
<ArrowRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Scenario Comparison */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Scenario Analysis
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{scenarioData.map((scenario, idx) => (
<div
key={idx}
className={`p-6 rounded-xl border-2 text-center ${
idx === 1
? "bg-[#0A39DF] text-white border-[#0A39DF]"
: "bg-white border-slate-200"
}`}
>
<p className={`text-sm font-medium mb-2 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
{scenario.scenario}
</p>
<p className={`text-3xl font-bold ${idx === 1 ? "text-white" : "text-slate-900"}`}>
${scenario.savings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className={`text-sm mt-1 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
annual savings
</p>
{idx === 1 && (
<Badge className="mt-3 bg-white/20 text-white border-0">
Recommended
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,359 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
DollarSign, TrendingUp, Target, Users, Zap,
ArrowUpRight, CheckCircle, AlertTriangle, Shield,
Clock, Award, Calendar, Package, Star, BarChart3
} from "lucide-react";
export default function SavingsOverviewCards({ metrics, projections, timeRange, userRole }) {
const getProjectedSavings = () => {
switch (timeRange) {
case "7days": return projections.sevenDays;
case "30days": return projections.thirtyDays;
case "quarter": return projections.quarter;
case "year": return projections.year;
default: return projections.thirtyDays;
}
};
const getTimeLabel = () => {
switch (timeRange) {
case "7days": return "7-Day";
case "30days": return "30-Day";
case "quarter": return "Quarterly";
case "year": return "Annual";
default: return "30-Day";
}
};
// Role-specific card configurations
const getRoleCards = () => {
switch (userRole) {
case "procurement":
return [
{
title: "Vendor Performance Score",
value: `${(metrics.avgReliability + 5).toFixed(0)}%`,
change: `Top ${metrics.activeVendors} vendors tracked`,
trend: "up",
icon: Award,
color: "blue",
description: "Network-wide average",
},
{
title: "Contract Compliance",
value: `${metrics.contractedRatio.toFixed(1)}%`,
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-compliant`,
trend: metrics.contractedRatio > 70 ? "up" : "down",
icon: Shield,
color: metrics.contractedRatio > 70 ? "green" : "red",
description: "Spend under contract",
},
{
title: "Rate Optimization",
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`,
change: "per hour savings potential",
trend: "up",
icon: DollarSign,
color: "emerald",
description: "Contract vs. spot rates",
},
{
title: "SLA Adherence",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: CheckCircle,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Vendor delivery rate",
},
{
title: "Network Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${getTimeLabel()} projection`,
trend: "up",
icon: TrendingUp,
color: "purple",
description: "From vendor optimization",
},
];
case "operator":
return [
{
title: "Enterprise Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders fulfilled`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Cross-sector average",
},
{
title: "Labor Efficiency",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% absence rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: "blue",
description: "Workforce productivity",
},
{
title: "Cost per Order",
value: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`,
change: "average fulfillment cost",
trend: "up",
icon: DollarSign,
color: "purple",
description: "Operational efficiency",
},
{
title: "Sector Coverage",
value: `${metrics.activeVendors}`,
change: "active vendor partners",
trend: "up",
icon: Package,
color: "indigo",
description: "Available resources",
},
{
title: "Operational Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${getTimeLabel()} potential`,
trend: "up",
icon: TrendingUp,
color: "emerald",
description: "From efficiency gains",
},
];
case "sector":
return [
{
title: "Location Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${100 - metrics.fillRate > 0 ? (100 - metrics.fillRate).toFixed(1) + '% gaps' : 'No gaps'}`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Position coverage",
},
{
title: "Staff Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-shows`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "blue" : "amber",
description: "At your location",
},
{
title: "Weekly Hours",
value: `${Math.floor(metrics.totalWorkforce * 32)}`,
change: "scheduled this period",
trend: "up",
icon: Clock,
color: "purple",
description: "Labor hours planned",
},
{
title: "Local Spend",
value: `$${metrics.totalSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: "labor investment",
trend: "up",
icon: DollarSign,
color: "slate",
description: "Your location budget",
},
];
case "client":
return [
{
title: "Event Coverage",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} events staffed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Calendar,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Position fill rate",
},
{
title: "Staff Quality",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: "reliability score",
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Star,
color: metrics.avgReliability > 85 ? "amber" : "orange",
description: "Assigned workforce",
},
{
title: "Cost Savings",
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${metrics.potentialSavingsPercent.toFixed(0)}% vs. spot rates`,
trend: "up",
icon: DollarSign,
color: "emerald",
description: `${getTimeLabel()} savings`,
},
{
title: "On-Time Rate",
value: `${(100 - metrics.noShowRate).toFixed(1)}%`,
change: "staff attendance",
trend: metrics.noShowRate < 5 ? "up" : "down",
icon: CheckCircle,
color: metrics.noShowRate < 5 ? "green" : "amber",
description: "Punctuality score",
},
];
case "vendor":
return [
{
title: "Your Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders completed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: Target,
color: metrics.fillRate > 90 ? "green" : "amber",
description: "Order fulfillment",
},
{
title: "Workforce Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "blue" : "orange",
description: "Your team score",
},
{
title: "Competitive Edge",
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`,
change: "savings vs. gig rates",
trend: "up",
icon: Zap,
color: "amber",
description: "Your value proposition",
},
{
title: "Revenue Potential",
value: `$${(metrics.totalSpend * 1.2).toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: "if 100% fill rate",
trend: "up",
icon: TrendingUp,
color: "purple",
description: "Growth opportunity",
},
{
title: "Active Workforce",
value: metrics.totalWorkforce.toString(),
change: "ready to deploy",
trend: "up",
icon: Shield,
color: "indigo",
description: "Available staff",
},
];
default: // admin
return [
{
title: `${getTimeLabel()} Potential Savings`,
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
change: `${metrics.potentialSavingsPercent.toFixed(1)}% opportunity`,
trend: "up",
icon: DollarSign,
color: "emerald",
description: "From contract conversion",
},
{
title: "Contract Coverage",
value: `${metrics.contractedRatio.toFixed(1)}%`,
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-contracted`,
trend: metrics.contractedRatio > 70 ? "up" : "down",
icon: Target,
color: metrics.contractedRatio > 70 ? "blue" : "amber",
description: "Labor under contract",
},
{
title: "Platform Reliability",
value: `${metrics.avgReliability.toFixed(0)}%`,
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
trend: metrics.avgReliability > 85 ? "up" : "down",
icon: Users,
color: metrics.avgReliability > 85 ? "purple" : "orange",
description: "Workforce average",
},
{
title: "Fill Rate",
value: `${metrics.fillRate.toFixed(1)}%`,
change: `${metrics.completedOrders} orders completed`,
trend: metrics.fillRate > 90 ? "up" : "down",
icon: CheckCircle,
color: metrics.fillRate > 90 ? "green" : "red",
description: "Order fulfillment",
},
{
title: "Network Size",
value: `${metrics.activeVendors} / ${metrics.totalWorkforce}`,
change: "vendors / workforce",
trend: "up",
icon: BarChart3,
color: "indigo",
description: "Platform capacity",
},
];
}
};
const cards = getRoleCards();
const colorClasses = {
emerald: { bg: "bg-emerald-50", icon: "bg-emerald-500", text: "text-emerald-700", badge: "bg-emerald-100 text-emerald-700" },
blue: { bg: "bg-blue-50", icon: "bg-blue-500", text: "text-blue-700", badge: "bg-blue-100 text-blue-700" },
purple: { bg: "bg-purple-50", icon: "bg-purple-500", text: "text-purple-700", badge: "bg-purple-100 text-purple-700" },
green: { bg: "bg-green-50", icon: "bg-green-500", text: "text-green-700", badge: "bg-green-100 text-green-700" },
amber: { bg: "bg-amber-50", icon: "bg-amber-500", text: "text-amber-700", badge: "bg-amber-100 text-amber-700" },
orange: { bg: "bg-orange-50", icon: "bg-orange-500", text: "text-orange-700", badge: "bg-orange-100 text-orange-700" },
red: { bg: "bg-red-50", icon: "bg-red-500", text: "text-red-700", badge: "bg-red-100 text-red-700" },
indigo: { bg: "bg-indigo-50", icon: "bg-indigo-500", text: "text-indigo-700", badge: "bg-indigo-100 text-indigo-700" },
slate: { bg: "bg-slate-50", icon: "bg-slate-500", text: "text-slate-700", badge: "bg-slate-100 text-slate-700" },
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{cards.map((card, index) => {
const colors = colorClasses[card.color];
const Icon = card.icon;
return (
<Card key={index} className={`${colors.bg} border-0 shadow-sm hover:shadow-md transition-all`}>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className={`w-12 h-12 ${colors.icon} rounded-xl flex items-center justify-center`}>
<Icon className="w-6 h-6 text-white" />
</div>
<Badge className={`${colors.badge} border-0 text-xs font-medium`}>
{card.trend === "up" ? (
<ArrowUpRight className="w-3 h-3 mr-1" />
) : (
<AlertTriangle className="w-3 h-3 mr-1" />
)}
{card.change}
</Badge>
</div>
<p className={`text-xs ${colors.text} uppercase tracking-wider font-semibold mb-1`}>
{card.title}
</p>
<p className={`text-3xl font-bold ${colors.text}`}>{card.value}</p>
<p className="text-xs text-slate-500 mt-1">{card.description}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,299 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Package, Star, TrendingUp, TrendingDown, DollarSign,
Users, CheckCircle, AlertTriangle, Award, Shield, Zap
} from "lucide-react";
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis, Cell } from "recharts";
export default function VendorPerformanceMatrix({ vendors, assignments, rates, metrics, userRole }) {
// Generate vendor performance data
const vendorPerformance = vendors.slice(0, 8).map((vendor, idx) => ({
id: vendor.id,
name: vendor.legal_name || vendor.doing_business_as || `Vendor ${idx + 1}`,
region: vendor.region || "Bay Area",
avgRate: metrics.avgContractedRate - (Math.random() * 10 - 5),
reliability: 80 + Math.random() * 18,
fillRate: 85 + Math.random() * 14,
noShowRate: Math.random() * 5,
onTimeRate: 88 + Math.random() * 11,
workforce: vendor.workforce_count || Math.floor(50 + Math.random() * 150),
spend: Math.floor(10000 + Math.random() * 50000),
score: Math.floor(70 + Math.random() * 28),
tier: idx < 2 ? "Preferred" : idx < 5 ? "Approved" : "Standard",
savingsPotential: Math.floor(1000 + Math.random() * 5000),
}));
// Scatter plot data
const scatterData = vendorPerformance.map(v => ({
x: v.avgRate,
y: v.reliability,
z: v.spend / 1000,
name: v.name,
tier: v.tier,
}));
const tierColors = {
Preferred: "#22c55e",
Approved: "#0A39DF",
Standard: "#f59e0b",
};
const tierBadgeColors = {
Preferred: "bg-green-100 text-green-700",
Approved: "bg-blue-100 text-blue-700",
Standard: "bg-amber-100 text-amber-700",
};
const slaMetrics = [
{ metric: "Response Time", target: "< 2 hours", achieved: "1.5 hours", status: "met" },
{ metric: "Fill Rate", target: "> 95%", achieved: `${metrics.fillRate.toFixed(1)}%`, status: metrics.fillRate > 95 ? "met" : "at-risk" },
{ metric: "No-Show Rate", target: "< 3%", achieved: `${metrics.noShowRate.toFixed(1)}%`, status: metrics.noShowRate < 3 ? "met" : "at-risk" },
{ metric: "On-Time Arrival", target: "> 98%", achieved: "97.2%", status: "at-risk" },
];
return (
<div className="space-y-6">
{/* Vendor Scorecard Summary */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-green-50 border-green-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
<Star className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-green-600 font-medium">Preferred Vendors</p>
<p className="text-2xl font-bold text-green-900">{vendorPerformance.filter(v => v.tier === "Preferred").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center">
<Shield className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-blue-600 font-medium">Approved Vendors</p>
<p className="text-2xl font-bold text-blue-900">{vendorPerformance.filter(v => v.tier === "Approved").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-amber-50 border-amber-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center">
<Package className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-amber-600 font-medium">Standard Vendors</p>
<p className="text-2xl font-bold text-amber-900">{vendorPerformance.filter(v => v.tier === "Standard").length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-purple-50 border-purple-200">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-purple-600 font-medium">Total Savings Potential</p>
<p className="text-2xl font-bold text-purple-900">
${vendorPerformance.reduce((sum, v) => sum + v.savingsPotential, 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Rate vs Reliability Scatter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-blue-500" />
Vendor Performance Matrix (Rate vs. Reliability)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
type="number"
dataKey="x"
name="Rate"
unit="$/hr"
stroke="#64748b"
fontSize={12}
label={{ value: 'Hourly Rate ($)', position: 'bottom', offset: 0 }}
/>
<YAxis
type="number"
dataKey="y"
name="Reliability"
unit="%"
stroke="#64748b"
fontSize={12}
domain={[70, 100]}
label={{ value: 'Reliability (%)', angle: -90, position: 'left' }}
/>
<ZAxis type="number" dataKey="z" range={[100, 500]} />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ payload }) => {
if (payload && payload[0]) {
const data = payload[0].payload;
return (
<div className="bg-white p-3 border border-slate-200 rounded-lg shadow-lg">
<p className="font-bold text-slate-900">{data.name}</p>
<p className="text-sm text-slate-600">Rate: ${data.x.toFixed(2)}/hr</p>
<p className="text-sm text-slate-600">Reliability: {data.y.toFixed(1)}%</p>
<Badge className={tierBadgeColors[data.tier]}>{data.tier}</Badge>
</div>
);
}
return null;
}}
/>
<Scatter data={scatterData}>
{scatterData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={tierColors[entry.tier]} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-4">
{Object.entries(tierColors).map(([tier, color]) => (
<div key={tier} className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: color }} />
<span className="text-sm text-slate-600">{tier}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Vendor Table */}
<Card>
<CardHeader>
<CardTitle>Vendor Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Vendor</TableHead>
<TableHead>Tier</TableHead>
<TableHead className="text-right">Avg Rate</TableHead>
<TableHead className="text-right">Reliability</TableHead>
<TableHead className="text-right">Fill Rate</TableHead>
<TableHead className="text-right">No-Show</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead className="text-right">Savings Potential</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendorPerformance.map((vendor) => (
<TableRow key={vendor.id} className="hover:bg-slate-50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#0A39DF] rounded-lg flex items-center justify-center">
<Package className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-medium text-slate-900">{vendor.name}</p>
<p className="text-xs text-slate-500">{vendor.region}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge className={tierBadgeColors[vendor.tier]}>{vendor.tier}</Badge>
</TableCell>
<TableCell className="text-right font-medium">${vendor.avgRate.toFixed(2)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Progress value={vendor.reliability} className="w-12 h-2" />
<span className="text-sm">{vendor.reliability.toFixed(0)}%</span>
</div>
</TableCell>
<TableCell className="text-right">{vendor.fillRate.toFixed(0)}%</TableCell>
<TableCell className="text-right">
<span className={vendor.noShowRate < 3 ? "text-green-600" : "text-red-600"}>
{vendor.noShowRate.toFixed(1)}%
</span>
</TableCell>
<TableCell className="text-right">
<Badge className={vendor.score >= 90 ? "bg-green-100 text-green-700" : vendor.score >= 75 ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}>
{vendor.score}
</Badge>
</TableCell>
<TableCell className="text-right font-semibold text-green-600">
${vendor.savingsPotential.toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* SLA Tracking */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-amber-500" />
Service Level Agreement (SLA) Tracking
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{slaMetrics.map((sla, idx) => (
<div key={idx} className={`p-4 rounded-xl border-2 ${sla.status === "met" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200"}`}>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-700">{sla.metric}</span>
{sla.status === "met" ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<AlertTriangle className="w-5 h-5 text-amber-500" />
)}
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Target</span>
<span className="font-medium">{sla.target}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Achieved</span>
<span className={`font-bold ${sla.status === "met" ? "text-green-600" : "text-amber-600"}`}>
{sla.achieved}
</span>
</div>
</div>
<Badge className={`mt-2 ${sla.status === "met" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"}`}>
{sla.status === "met" ? "SLA Met" : "At Risk"}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react"
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
const TOAST_LIMIT = 5
const TOAST_REMOVE_DELAY = 1000000
@@ -95,12 +95,7 @@ function dispatch(action) {
// Helper function to create notification in ActivityLog instead of toast
async function createNotification(title, description, variant) {
try {
const user = await krowSDK.auth.me();
if (!user) {
console.warn("Cannot create notification: user not authenticated.");
return;
}
const user = await base44.auth.me();
// Determine icon and color based on variant and title
let icon_type = "check";
@@ -129,7 +124,7 @@ async function createNotification(title, description, variant) {
activity_type = "staff_assigned";
}
const payload = {
await base44.entities.ActivityLog.create({
title: title.replace(/✅|❌|⚠️/g, '').trim(),
description: description || "",
activity_type: activity_type,
@@ -137,10 +132,7 @@ async function createNotification(title, description, variant) {
is_read: false,
icon_type: icon_type,
icon_color: icon_color,
};
await krowSDK.entities.ActivityLog.create({ data: payload });
});
} catch (error) {
console.error("Failed to create notification:", error);
}

View File

@@ -0,0 +1,194 @@
import React from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import {
Heart, HeartOff, Shield, AlertTriangle, TrendingUp,
Users, Lock, Unlock, Star, DollarSign, ArrowRight
} from "lucide-react";
export default function ClientLoyaltyCard({ vendorId, vendorName }) {
// Fetch all users to check who has this vendor locked
const { data: users = [] } = useQuery({
queryKey: ['users-loyalty', vendorId],
queryFn: async () => {
const allUsers = await base44.entities.User.list();
return allUsers.filter(u =>
u.user_role === 'client' || u.role === 'client'
);
},
initialData: [],
});
// Fetch businesses/clients that have worked with this vendor
const { data: events = [] } = useQuery({
queryKey: ['vendor-events-loyalty', vendorId],
queryFn: async () => {
const allEvents = await base44.entities.Event.list();
return allEvents.filter(e =>
e.vendor_name === vendorName || e.vendor_id === vendorId
);
},
initialData: [],
});
// Get unique clients from events
const uniqueClients = [...new Set(events.map(e => e.business_name).filter(Boolean))];
// Categorize clients
const loyalClients = users.filter(u =>
u.locked_vendor_ids?.includes(vendorId) ||
u.preferred_vendor_id === vendorId
);
const atRiskClients = users.filter(u =>
u.allow_procurement_optimization !== false &&
!u.locked_vendor_ids?.includes(vendorId) &&
u.preferred_vendor_id !== vendorId &&
uniqueClients.some(c =>
u.company_name === c || u.full_name?.includes(c?.split(' ')[0])
)
);
const loyalCount = loyalClients.length;
const atRiskCount = atRiskClients.length;
const totalClients = uniqueClients.length || 1;
const loyaltyScore = totalClients > 0 ? Math.round((loyalCount / totalClients) * 100) : 0;
return (
<Card className="bg-white border-slate-200 shadow-sm">
<CardHeader className="pb-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Heart className="w-4 h-4 text-pink-600" />
Client Loyalty
</CardTitle>
<Badge className={`${loyaltyScore >= 70 ? 'bg-green-100 text-green-700' : loyaltyScore >= 40 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}`}>
{loyaltyScore}% Retention
</Badge>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Loyalty Score Bar */}
<div>
<div className="flex items-center justify-between text-xs mb-2">
<span className="text-slate-500">Client Retention Score</span>
<span className="font-bold text-slate-700">{loyalCount}/{totalClients} locked</span>
</div>
<Progress
value={loyaltyScore}
className="h-2"
/>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-2 mb-1">
<Lock className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-green-700">Loyal</span>
</div>
<p className="text-2xl font-bold text-green-700">{loyalCount}</p>
<p className="text-xs text-green-600">Won't switch vendors</p>
</div>
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-amber-700">At Risk</span>
</div>
<p className="text-2xl font-bold text-amber-700">{atRiskCount}</p>
<p className="text-xs text-amber-600">Open to optimization</p>
</div>
</div>
{/* Loyal Clients List */}
{loyalClients.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-2 flex items-center gap-1">
<Shield className="w-3 h-3 text-green-600" />
Protected Relationships
</p>
<div className="space-y-2">
{loyalClients.slice(0, 3).map((client, idx) => (
<div
key={client.id || idx}
className="flex items-center gap-2 p-2 bg-green-50/50 rounded-lg"
>
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-green-200 text-green-800 text-xs font-bold">
{client.full_name?.charAt(0) || client.company_name?.charAt(0) || 'C'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-900 truncate">
{client.company_name || client.full_name}
</p>
</div>
<Badge className="bg-green-100 text-green-700 text-[10px]">
<Lock className="w-2.5 h-2.5 mr-0.5" />
Locked
</Badge>
</div>
))}
</div>
</div>
)}
{/* At Risk Clients */}
{atRiskClients.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-600 mb-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-amber-600" />
Needs Attention
</p>
<div className="space-y-2">
{atRiskClients.slice(0, 3).map((client, idx) => (
<div
key={client.id || idx}
className="flex items-center gap-2 p-2 bg-amber-50/50 rounded-lg"
>
<Avatar className="w-7 h-7">
<AvatarFallback className="bg-amber-200 text-amber-800 text-xs font-bold">
{client.full_name?.charAt(0) || client.company_name?.charAt(0) || 'C'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-900 truncate">
{client.company_name || client.full_name}
</p>
</div>
<Badge className="bg-amber-100 text-amber-700 text-[10px]">
<Unlock className="w-2.5 h-2.5 mr-0.5" />
At Risk
</Badge>
</div>
))}
</div>
</div>
)}
{/* Tips */}
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-xs font-medium text-blue-800 mb-1">💡 Retention Tips</p>
<ul className="text-xs text-blue-700 space-y-1">
<li> Maintain high fill rates to keep clients happy</li>
<li> Respond quickly to urgent requests</li>
<li> Offer competitive rates to at-risk clients</li>
</ul>
</div>
{/* No Data State */}
{totalClients === 0 && (
<div className="text-center py-4">
<Users className="w-8 h-8 mx-auto mb-2 text-slate-300" />
<p className="text-xs text-slate-500">No client data yet</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,386 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import {
Lock, Unlock, Shield, DollarSign, TrendingUp, AlertTriangle,
CheckCircle, X, Star, Users, Zap, ArrowRight, Heart, HeartOff,
Settings, Info, Sparkles
} from "lucide-react";
export default function ClientVendorPreferences({ user, vendors, onUpdate }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [savingsModal, setSavingsModal] = useState({ open: false, vendor: null, savings: 0 });
// Get user's locked vendors (vendors they don't want changed)
const lockedVendorIds = user?.locked_vendor_ids || [];
const allowOptimization = user?.allow_procurement_optimization !== false; // Default true
// Calculate potential savings for each non-preferred vendor
const vendorsWithSavings = vendors.map(vendor => {
const currentRate = vendor.avgRate || 50;
const preferredRate = 42; // Tier 1 rate
const savingsPercent = currentRate > preferredRate ? ((currentRate - preferredRate) / currentRate * 100) : 0;
const monthlySavings = savingsPercent > 0 ? (currentRate - preferredRate) * 40 * 4 : 0; // 40hrs/week * 4 weeks
return {
...vendor,
potentialSavingsPercent: savingsPercent,
potentialMonthlySavings: monthlySavings,
isLocked: lockedVendorIds.includes(vendor.id),
};
});
const updatePreferencesMutation = useMutation({
mutationFn: (data) => base44.auth.updateMe(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] });
onUpdate?.();
},
});
const toggleVendorLock = (vendorId, vendorName) => {
const newLockedIds = lockedVendorIds.includes(vendorId)
? lockedVendorIds.filter(id => id !== vendorId)
: [...lockedVendorIds, vendorId];
updatePreferencesMutation.mutate({ locked_vendor_ids: newLockedIds });
toast({
title: lockedVendorIds.includes(vendorId) ? "Vendor Unlocked" : "Vendor Locked",
description: lockedVendorIds.includes(vendorId)
? `${vendorName} can now be optimized by Procurement`
: `${vendorName} will not be changed without your approval`,
});
};
const toggleOptimization = (allowed) => {
updatePreferencesMutation.mutate({ allow_procurement_optimization: allowed });
toast({
title: allowed ? "Optimization Enabled" : "Optimization Disabled",
description: allowed
? "Procurement can suggest vendor optimizations for cost savings"
: "Your vendor relationships will remain unchanged",
});
};
const handleViewSavings = (vendor) => {
setSavingsModal({
open: true,
vendor,
savings: vendor.potentialMonthlySavings,
});
};
const handleAcceptSavings = () => {
// Remove from locked list if locked
if (savingsModal.vendor?.isLocked) {
const newLockedIds = lockedVendorIds.filter(id => id !== savingsModal.vendor.id);
updatePreferencesMutation.mutate({ locked_vendor_ids: newLockedIds });
}
toast({
title: "Optimization Request Submitted",
description: `We'll find a better rate for your ${savingsModal.vendor?.legal_name} positions`,
});
setSavingsModal({ open: false, vendor: null, savings: 0 });
};
const totalPotentialSavings = vendorsWithSavings
.filter(v => !v.isLocked && v.potentialMonthlySavings > 0)
.reduce((sum, v) => sum + v.potentialMonthlySavings, 0);
const lockedVendors = vendorsWithSavings.filter(v => v.isLocked);
const optimizableVendors = vendorsWithSavings.filter(v => !v.isLocked && v.potentialSavingsPercent > 5);
return (
<div className="space-y-4">
{/* Main Control Card */}
<Card className="border-2 border-slate-200 bg-gradient-to-br from-white to-slate-50">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#0A39DF] rounded-xl flex items-center justify-center">
<Settings className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg">Vendor Preferences</CardTitle>
<p className="text-sm text-slate-500">Control how your vendors are managed</p>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Optimization Toggle */}
<div className="flex items-center justify-between p-4 bg-white rounded-xl border border-slate-200">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
allowOptimization ? 'bg-emerald-100' : 'bg-slate-100'
}`}>
{allowOptimization ? (
<Sparkles className="w-5 h-5 text-emerald-600" />
) : (
<Lock className="w-5 h-5 text-slate-500" />
)}
</div>
<div>
<p className="font-semibold text-slate-900">Allow Procurement Optimization</p>
<p className="text-sm text-slate-500">
{allowOptimization
? "Procurement can suggest cost-saving vendor changes"
: "Your vendor relationships remain unchanged"
}
</p>
</div>
</div>
<Switch
checked={allowOptimization}
onCheckedChange={toggleOptimization}
/>
</div>
{/* Potential Savings Banner */}
{allowOptimization && totalPotentialSavings > 0 && (
<div className="p-4 bg-gradient-to-r from-emerald-50 to-green-50 rounded-xl border border-emerald-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center">
<DollarSign className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-bold text-emerald-900">Potential Monthly Savings</p>
<p className="text-sm text-emerald-700">
{optimizableVendors.length} vendor(s) eligible for optimization
</p>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-emerald-600">
${totalPotentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className="text-xs text-emerald-600">per month</p>
</div>
</div>
</div>
)}
{/* Locked Vendors */}
{lockedVendors.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Lock className="w-4 h-4 text-amber-600" />
<p className="font-semibold text-slate-700 text-sm">
Locked Vendors ({lockedVendors.length})
</p>
<Badge className="bg-amber-100 text-amber-700 text-xs">Protected</Badge>
</div>
<div className="space-y-2">
{lockedVendors.map(vendor => (
<div
key={vendor.id}
className="flex items-center justify-between p-3 bg-amber-50 rounded-lg border border-amber-200"
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-amber-200 text-amber-800 font-bold">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-slate-900">{vendor.legal_name}</p>
<p className="text-xs text-slate-500">${vendor.avgRate?.toFixed(0)}/hr avg</p>
</div>
</div>
<div className="flex items-center gap-2">
{vendor.potentialMonthlySavings > 0 && (
<Badge className="bg-emerald-100 text-emerald-700 text-xs">
Could save ${vendor.potentialMonthlySavings.toFixed(0)}/mo
</Badge>
)}
<Button
size="sm"
variant="ghost"
onClick={() => toggleVendorLock(vendor.id, vendor.legal_name)}
className="text-amber-700 hover:text-amber-900 hover:bg-amber-100"
>
<Unlock className="w-4 h-4 mr-1" />
Unlock
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Optimization Opportunities */}
{allowOptimization && optimizableVendors.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<p className="font-semibold text-slate-700 text-sm">
Savings Opportunities ({optimizableVendors.length})
</p>
</div>
<div className="space-y-2">
{optimizableVendors.slice(0, 3).map(vendor => (
<div
key={vendor.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-emerald-300 hover:bg-emerald-50/50 transition-all"
>
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-slate-100 text-slate-700 font-bold">
{vendor.legal_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-slate-900">{vendor.legal_name}</p>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">${vendor.avgRate?.toFixed(0)}/hr</span>
<ArrowRight className="w-3 h-3 text-emerald-500" />
<span className="text-xs text-emerald-600 font-medium">$42/hr</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-right mr-2">
<p className="font-bold text-emerald-600">
Save ${vendor.potentialMonthlySavings.toFixed(0)}
</p>
<p className="text-xs text-slate-500">per month</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => toggleVendorLock(vendor.id, vendor.legal_name)}
className="border-amber-300 text-amber-700 hover:bg-amber-50"
>
<Lock className="w-3 h-3 mr-1" />
Keep
</Button>
<Button
size="sm"
onClick={() => handleViewSavings(vendor)}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
<Zap className="w-3 h-3 mr-1" />
Optimize
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Info Box */}
<div className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<Info className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium">How this works:</p>
<ul className="mt-1 space-y-1 text-blue-700">
<li> <strong>Locked vendors</strong> won't be changed without your approval</li>
<li> <strong>Optimization</strong> moves orders to preferred vendors for better rates</li>
<li> You can lock/unlock vendors anytime</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Savings Confirmation Modal */}
<Dialog open={savingsModal.open} onOpenChange={(open) => setSavingsModal({ ...savingsModal, open })}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-600" />
Switch to Preferred Vendor?
</DialogTitle>
<DialogDescription>
Optimize your staffing costs while maintaining quality
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Current vs New */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-amber-50 rounded-xl border border-amber-200 text-center">
<p className="text-xs text-amber-700 font-medium mb-1">CURRENT</p>
<p className="font-bold text-slate-900">{savingsModal.vendor?.legal_name}</p>
<p className="text-2xl font-bold text-amber-600 mt-2">
${savingsModal.vendor?.avgRate?.toFixed(0)}/hr
</p>
</div>
<div className="p-4 bg-emerald-50 rounded-xl border border-emerald-200 text-center">
<p className="text-xs text-emerald-700 font-medium mb-1">PREFERRED</p>
<p className="font-bold text-slate-900">Tier 1 Vendor</p>
<p className="text-2xl font-bold text-emerald-600 mt-2">$42/hr</p>
</div>
</div>
{/* Savings Highlight */}
<div className="p-4 bg-gradient-to-r from-emerald-500 to-green-500 rounded-xl text-white text-center">
<p className="text-sm opacity-90 mb-1">Your Monthly Savings</p>
<p className="text-4xl font-bold">
${savingsModal.savings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
</p>
<p className="text-sm opacity-90 mt-1">Same quality staff Better rates</p>
</div>
{/* Benefits */}
<div className="space-y-2">
{[
"Same quality staff from verified vendors",
"Priority support and faster response times",
"Dedicated account manager",
].map((benefit, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-slate-700">
<CheckCircle className="w-4 h-4 text-emerald-500" />
{benefit}
</div>
))}
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
toggleVendorLock(savingsModal.vendor?.id, savingsModal.vendor?.legal_name);
setSavingsModal({ open: false, vendor: null, savings: 0 });
}}
className="border-amber-300 text-amber-700 hover:bg-amber-50"
>
<Lock className="w-4 h-4 mr-2" />
Keep Current Vendor
</Button>
<Button
onClick={handleAcceptSavings}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Zap className="w-4 h-4 mr-2" />
Switch & Save ${savingsModal.savings.toFixed(0)}/mo
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,408 @@
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Sparkles, Target, TrendingUp, DollarSign, Users, Clock,
Shield, Zap, Brain, CheckCircle, ArrowRight, BarChart3,
Building2, MapPin, Calendar, Lightbulb, AlertTriangle
} from "lucide-react";
const STRATEGY_CATEGORIES = [
{ id: "cost", label: "Cost Optimization", icon: DollarSign, color: "green" },
{ id: "efficiency", label: "Operational Efficiency", icon: Zap, color: "blue" },
{ id: "quality", label: "Quality & Compliance", icon: Shield, color: "purple" },
{ id: "growth", label: "Revenue Growth", icon: TrendingUp, color: "amber" }
];
const SMART_STRATEGIES = {
cost: [
{
id: "vendor_consolidation",
title: "Vendor Consolidation Program",
description: "Reduce vendor count by 40% while maintaining service quality through strategic consolidation",
impact: "15-25% cost reduction",
timeframe: "90 days",
roi: "$45,000/year",
steps: [
"Analyze current vendor performance scores",
"Identify overlap in service coverage",
"Negotiate volume discounts with top performers",
"Transition low-performers to preferred vendors"
],
metrics: ["Vendor count reduction", "Rate savings %", "Fill rate impact"]
},
{
id: "rate_optimization",
title: "Dynamic Rate Optimization",
description: "AI-powered rate adjustments based on demand, seasonality, and market conditions",
impact: "8-12% savings on labor costs",
timeframe: "30 days",
roi: "$28,000/year",
steps: [
"Enable real-time market rate monitoring",
"Set rate caps based on role and region",
"Auto-negotiate when rates exceed thresholds",
"Lock in favorable rates for recurring positions"
],
metrics: ["Average rate reduction", "Market position", "Compliance rate"]
},
{
id: "overtime_reduction",
title: "Smart Overtime Management",
description: "Predictive scheduling to minimize costly overtime while maintaining coverage",
impact: "30-40% OT reduction",
timeframe: "45 days",
roi: "$18,000/year",
steps: [
"Analyze historical overtime patterns",
"Implement 8-hour shift maximums",
"Cross-train workers for flexibility",
"Use AI for shift optimization"
],
metrics: ["OT hours/week", "Cost per shift", "Worker satisfaction"]
}
],
efficiency: [
{
id: "auto_assignment",
title: "Automated Assignment Engine",
description: "AI matches workers to shifts based on skills, location, availability, and performance",
impact: "85% faster assignment",
timeframe: "14 days",
roi: "$22,000/year",
steps: [
"Enable smart matching algorithm",
"Define assignment rules and priorities",
"Set up worker preference profiles",
"Activate real-time notifications"
],
metrics: ["Time to fill", "Assignment accuracy", "Worker acceptance rate"]
},
{
id: "predictive_scheduling",
title: "Predictive Demand Scheduling",
description: "Forecast staffing needs 2-4 weeks ahead using historical data and event calendars",
impact: "95% schedule accuracy",
timeframe: "21 days",
roi: "$15,000/year",
steps: [
"Integrate historical demand data",
"Connect event and holiday calendars",
"Train prediction models",
"Automate schedule generation"
],
metrics: ["Forecast accuracy", "Understaffing incidents", "Overstaffing costs"]
},
{
id: "digital_timesheet",
title: "Digital Timesheet Automation",
description: "Eliminate manual time tracking with GPS-verified clock-in/out and auto-approval",
impact: "90% admin time saved",
timeframe: "7 days",
roi: "$12,000/year",
steps: [
"Deploy mobile clock-in app",
"Configure geofencing rules",
"Set up auto-approval thresholds",
"Enable real-time dashboard"
],
metrics: ["Timesheet accuracy", "Processing time", "Dispute rate"]
}
],
quality: [
{
id: "compliance_monitoring",
title: "Real-Time Compliance Dashboard",
description: "Continuous monitoring of certifications, background checks, and regulatory requirements",
impact: "100% compliance rate",
timeframe: "14 days",
roi: "Risk mitigation",
steps: [
"Integrate all compliance data sources",
"Set up expiration alerts (30/60/90 days)",
"Automate renewal reminders",
"Generate audit-ready reports"
],
metrics: ["Compliance score", "Expired certifications", "Audit pass rate"]
},
{
id: "performance_scoring",
title: "Worker Performance Index",
description: "Data-driven scoring system for reliability, quality, and client satisfaction",
impact: "25% quality improvement",
timeframe: "30 days",
roi: "$8,000/year",
steps: [
"Define performance metrics",
"Collect feedback from all parties",
"Calculate composite scores",
"Reward top performers"
],
metrics: ["Average rating", "Repeat request rate", "Client satisfaction"]
},
{
id: "incident_reduction",
title: "Proactive Incident Prevention",
description: "AI identifies high-risk assignments before issues occur",
impact: "60% fewer incidents",
timeframe: "45 days",
roi: "Risk mitigation",
steps: [
"Analyze historical incident data",
"Identify risk patterns",
"Flag high-risk assignments",
"Implement preventive protocols"
],
metrics: ["Incident rate", "Response time", "Resolution rate"]
}
],
growth: [
{
id: "market_expansion",
title: "Strategic Market Expansion",
description: "Data-driven identification of high-opportunity markets for vendor growth",
impact: "+35% addressable market",
timeframe: "60 days",
roi: "$65,000/year",
steps: [
"Analyze demand gaps by region",
"Identify underserved sectors",
"Recruit targeted vendor partners",
"Launch market-specific campaigns"
],
metrics: ["New markets entered", "Revenue per market", "Market share"]
},
{
id: "client_retention",
title: "Client Loyalty Program",
description: "Tiered benefits and exclusive rates for high-volume clients",
impact: "40% higher retention",
timeframe: "30 days",
roi: "$42,000/year",
steps: [
"Segment clients by value",
"Design tier benefits",
"Implement loyalty tracking",
"Launch exclusive offerings"
],
metrics: ["Client retention rate", "CLV increase", "Referral rate"]
},
{
id: "upsell_automation",
title: "Smart Upsell Engine",
description: "AI identifies opportunities to expand services with existing clients",
impact: "+18% revenue per client",
timeframe: "21 days",
roi: "$28,000/year",
steps: [
"Analyze client usage patterns",
"Identify cross-sell opportunities",
"Automate personalized offers",
"Track conversion rates"
],
metrics: ["Upsell rate", "Average order value", "Service expansion"]
}
]
};
export default function SmartOperationStrategies({ userRole = 'vendor', onSelectStrategy }) {
const [selectedCategory, setSelectedCategory] = useState("cost");
const [expandedStrategy, setExpandedStrategy] = useState(null);
const strategies = SMART_STRATEGIES[selectedCategory] || [];
// Calculate total potential savings
const totalPotentialSavings = Object.values(SMART_STRATEGIES)
.flat()
.reduce((sum, strategy) => {
const roiMatch = strategy.roi.match(/\$(\d+,?\d*)/);
if (roiMatch) {
return sum + parseInt(roiMatch[1].replace(',', ''));
}
return sum;
}, 0);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center">
<Brain className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Smart Operation Strategies</h2>
<p className="text-sm text-slate-500">AI-powered recommendations to optimize your operations</p>
</div>
</div>
<Badge className="bg-green-100 text-green-700 text-sm px-3 py-1">
${totalPotentialSavings.toLocaleString()}/year potential
</Badge>
</div>
{/* Impact Summary */}
<Card className="bg-gradient-to-r from-emerald-50 to-blue-50 border-2 border-emerald-200">
<CardContent className="p-4">
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<p className="text-2xl font-bold text-emerald-700">15-25%</p>
<p className="text-xs text-emerald-600">Cost Reduction</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-blue-700">85%</p>
<p className="text-xs text-blue-600">Faster Operations</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-700">100%</p>
<p className="text-xs text-purple-600">Compliance Rate</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-amber-700">+35%</p>
<p className="text-xs text-amber-600">Revenue Growth</p>
</div>
</div>
</CardContent>
</Card>
{/* Category Tabs */}
<div className="flex gap-2 flex-wrap">
{STRATEGY_CATEGORIES.map(cat => {
const Icon = cat.icon;
const isActive = selectedCategory === cat.id;
return (
<Button
key={cat.id}
variant={isActive ? "default" : "outline"}
onClick={() => setSelectedCategory(cat.id)}
className={isActive ? "bg-[#0A39DF]" : ""}
>
<Icon className="w-4 h-4 mr-2" />
{cat.label}
</Button>
);
})}
</div>
{/* Strategy Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{strategies.map((strategy) => {
const isExpanded = expandedStrategy === strategy.id;
return (
<Card
key={strategy.id}
className={`cursor-pointer transition-all hover:shadow-lg ${isExpanded ? 'md:col-span-3 border-2 border-[#0A39DF]' : ''}`}
onClick={() => setExpandedStrategy(isExpanded ? null : strategy.id)}
>
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#0A39DF]" />
<Badge className="bg-green-100 text-green-700 text-[10px]">{strategy.impact}</Badge>
</div>
<Badge variant="outline" className="text-[10px]">{strategy.timeframe}</Badge>
</div>
<h3 className="font-bold text-slate-900 mb-2">{strategy.title}</h3>
<p className="text-sm text-slate-600 mb-3">{strategy.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-sm">
<DollarSign className="w-4 h-4 text-green-600" />
<span className="font-semibold text-green-700">{strategy.roi}</span>
</div>
<Button size="sm" variant="ghost" className="text-[#0A39DF]">
{isExpanded ? 'Collapse' : 'Details'} <ArrowRight className="w-3 h-3 ml-1" />
</Button>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-4 pt-4 border-t space-y-4">
<div>
<h4 className="font-semibold text-sm mb-2 flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
Implementation Steps
</h4>
<div className="space-y-2">
{strategy.steps.map((step, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="w-5 h-5 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold flex-shrink-0">
{idx + 1}
</div>
<span className="text-sm text-slate-600">{step}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold text-sm mb-2 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-purple-600" />
Key Metrics to Track
</h4>
<div className="flex gap-2 flex-wrap">
{strategy.metrics.map((metric, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{metric}
</Badge>
))}
</div>
</div>
<div className="flex gap-3">
<Button
className="flex-1 bg-[#0A39DF] hover:bg-[#0831b8]"
onClick={(e) => {
e.stopPropagation();
onSelectStrategy?.(strategy);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Implement Strategy
</Button>
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
window.location.href = '/Support';
}}
>
Schedule Demo
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
{/* Quick Win Section */}
<Card className="border-l-4 border-l-amber-500 bg-amber-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lightbulb className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="flex-1">
<p className="font-semibold text-amber-900">Quick Win: Start with Rate Optimization</p>
<p className="text-sm text-amber-700 mt-1">
Enable dynamic rate optimization today no implementation required.
AI will start identifying savings opportunities within 24 hours with an average
8-12% reduction in labor costs.
</p>
</div>
<Button
size="sm"
className="bg-amber-600 hover:bg-amber-700"
onClick={() => window.location.href = '/VendorRates'}
>
Enable Now <Zap className="w-3 h-3 ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,90 +1,26 @@
import React from "react";
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { krowSDK } from "@/api/krowSDK";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { ArrowLeft } from "lucide-react";
import StaffForm from "@/components/staff/StaffForm";
export default function AddStaff() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { toast } = useToast();
const createStaffMutation = useMutation({
mutationFn: (staffPayload) => krowSDK.entities.Staff.create({ data: staffPayload }),
mutationFn: (staffData) => base44.entities.Staff.create(staffData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
toast({
title: "✅ Staff Member Added",
description: "The new staff member has been successfully created.",
});
navigate(createPageUrl("StaffDirectory"));
},
onError: (error) => {
toast({
title: "❌ Error Creating Staff",
description: error.message || "An unknown error occurred.",
variant: "destructive",
});
navigate(createPageUrl("Dashboard"));
},
});
const handleSubmit = (formData) => {
// 1. Map snake_case from form to camelCase for GraphQL
// 2. Transform enum values to uppercase
// 3. Add required fields not in the form
// 4. Filter out fields not in the mutation
const employmentTypeMap = {
"Full Time": "FULL_TIME",
"Part Time": "PART_TIME",
"On call": "ON_CALL",
"Weekends": "WEEKENDS",
"Specific Days": "SPECIFIC_DAYS",
"Seasonal": "SEASONAL",
"Medical Leave": "MEDICAL_LEAVE",
};
const englishLevelMap = {
"Fluent": "FLUENT",
"Intermediate": "INTERMEDIATE",
"Basic": "BASIC",
"None": "NONE",
};
const payload = {
// --- Fields from error messages ---
employeeName: formData.employee_name,
employmentType: employmentTypeMap[formData.employment_type],
english: englishLevelMap[formData.english],
backgroundCheckStatus: 'NOT_REQUIRED', // Default as it's missing from form
// --- Other likely fields (from form) ---
contactNumber: formData.contact_number,
hubLocation: formData.hub_location,
profileType: formData.profile_type,
reliabilityScore: parseInt(formData.reliability_score) || 100,
// --- Fields from form that might match schema ---
email: formData.email,
position: formData.position,
department: formData.department,
manager: formData.manager,
rate: parseFloat(formData.rate) || 0,
notes: formData.notes,
rating: parseFloat(formData.rating) || 0,
};
// Remove any keys with undefined values to keep the payload clean
Object.keys(payload).forEach(key => {
if (payload[key] === undefined || payload[key] === null) {
delete payload[key];
}
});
createStaffMutation.mutate(payload);
const handleSubmit = (staffData) => {
createStaffMutation.mutate(staffData);
};
return (
@@ -93,11 +29,11 @@ export default function AddStaff() {
<div className="mb-8">
<Button
variant="ghost"
onClick={() => navigate(createPageUrl("StaffDirectory"))}
onClick={() => navigate(createPageUrl("Dashboard"))}
className="mb-4 hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Staff Directory
Back to Dashboard
</Button>
<h1 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">Add New Staff Member</h1>
<p className="text-slate-600">Fill in the details to add a new team member</p>

View File

@@ -1,18 +1,767 @@
import React from "react";
import { Award } from "lucide-react";
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Award, Search, Plus, AlertTriangle, CheckCircle2, Clock, XCircle,
Download, Send, Eye, Edit2, ShieldCheck, FileText, Sparkles,
Calendar, User, Building2, ChevronRight, Filter, Bell, TrendingUp
} from "lucide-react";
import { format, differenceInDays, parseISO } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
const REQUIRED_CERTIFICATIONS = ["Background Check", "RBS", "Food Handler"];
const CERT_CONFIG = {
"Background Check": {
color: "from-purple-500 to-purple-600",
bgColor: "bg-purple-50",
textColor: "text-purple-700",
borderColor: "border-purple-200",
icon: ShieldCheck,
description: "Criminal background verification"
},
"RBS": {
color: "from-blue-500 to-blue-600",
bgColor: "bg-blue-50",
textColor: "text-blue-700",
borderColor: "border-blue-200",
icon: Award,
description: "Responsible Beverage Server"
},
"Food Handler": {
color: "from-emerald-500 to-emerald-600",
bgColor: "bg-emerald-50",
textColor: "text-emerald-700",
borderColor: "border-emerald-200",
icon: FileText,
description: "Food safety certification"
},
};
export default function Certification() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [certTypeFilter, setCertTypeFilter] = useState("all");
const [showAddModal, setShowAddModal] = useState(false);
const [editingCert, setEditingCert] = useState(null);
const [showReportModal, setShowReportModal] = useState(false);
const [selectedEmployee, setSelectedEmployee] = useState(null);
const { data: user } = useQuery({
queryKey: ['current-user-cert'],
queryFn: () => base44.auth.me(),
});
const { data: certifications = [] } = useQuery({
queryKey: ['certifications'],
queryFn: () => base44.entities.Certification.list(),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-for-cert'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
const isProcurement = userRole === "procurement";
const calculateStatus = (expiryDate) => {
if (!expiryDate) return "pending";
const days = differenceInDays(parseISO(expiryDate), new Date());
if (days < 0) return "expired";
if (days <= 30) return "expiring_soon";
return "current";
};
const processedCerts = useMemo(() => {
return certifications.map(cert => ({
...cert,
days_until_expiry: cert.expiry_date ? differenceInDays(parseISO(cert.expiry_date), new Date()) : null,
status: calculateStatus(cert.expiry_date),
}));
}, [certifications]);
const employeeCertMap = useMemo(() => {
const map = {};
staff.forEach(s => {
map[s.id] = {
employee_id: s.id,
employee_name: s.employee_name,
vendor_id: s.vendor_id,
vendor_name: s.vendor_name,
position: s.position,
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
allCurrent: false,
hasExpired: false,
hasExpiringSoon: false,
missingCount: 3,
canWork: false,
complianceScore: 0,
};
});
processedCerts.forEach(cert => {
const key = cert.employee_id;
if (!map[key]) {
map[key] = {
employee_id: cert.employee_id,
employee_name: cert.employee_name,
vendor_id: cert.vendor_id,
vendor_name: cert.vendor_name,
position: "",
certifications: { "Background Check": null, "RBS": null, "Food Handler": null },
allCurrent: false,
hasExpired: false,
hasExpiringSoon: false,
missingCount: 3,
canWork: false,
complianceScore: 0,
};
}
if (REQUIRED_CERTIFICATIONS.includes(cert.certification_type)) {
map[key].certifications[cert.certification_type] = cert;
}
});
Object.values(map).forEach(emp => {
const certs = Object.values(emp.certifications);
const validCerts = certs.filter(c => c && c.status === "current");
const expiredCerts = certs.filter(c => c && c.status === "expired");
const expiringSoonCerts = certs.filter(c => c && c.status === "expiring_soon");
const missingCerts = certs.filter(c => !c);
emp.allCurrent = validCerts.length === 3;
emp.hasExpired = expiredCerts.length > 0;
emp.hasExpiringSoon = expiringSoonCerts.length > 0;
emp.missingCount = missingCerts.length;
emp.canWork = validCerts.length === 3 || (validCerts.length + expiringSoonCerts.length === 3);
emp.complianceScore = Math.round(((validCerts.length + expiringSoonCerts.length * 0.5) / 3) * 100);
});
return map;
}, [processedCerts, staff]);
const employeeList = Object.values(employeeCertMap);
const filteredEmployees = useMemo(() => {
let filtered = employeeList;
if (isVendor && user?.vendor_id) {
filtered = filtered.filter(e => e.vendor_id === user.vendor_id);
}
if (searchTerm) {
filtered = filtered.filter(e =>
e.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (activeTab === "compliant") filtered = filtered.filter(e => e.allCurrent);
else if (activeTab === "expiring") filtered = filtered.filter(e => e.hasExpiringSoon);
else if (activeTab === "expired") filtered = filtered.filter(e => e.hasExpired);
else if (activeTab === "incomplete") filtered = filtered.filter(e => e.missingCount > 0);
if (certTypeFilter !== "all") {
filtered = filtered.filter(e => {
const cert = e.certifications[certTypeFilter];
return cert !== null;
});
}
return filtered;
}, [employeeList, searchTerm, activeTab, certTypeFilter, isVendor, user]);
const stats = useMemo(() => {
const total = employeeList.length;
const compliant = employeeList.filter(e => e.allCurrent).length;
const expiring = employeeList.filter(e => e.hasExpiringSoon).length;
const expired = employeeList.filter(e => e.hasExpired).length;
const incomplete = employeeList.filter(e => e.missingCount > 0).length;
const avgCompliance = total > 0 ? Math.round(employeeList.reduce((sum, e) => sum + e.complianceScore, 0) / total) : 0;
return { total, compliant, expiring, expired, incomplete, avgCompliance };
}, [employeeList]);
const saveCertMutation = useMutation({
mutationFn: async (data) => {
if (data.id) return base44.entities.Certification.update(data.id, data);
return base44.entities.Certification.create(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
setShowAddModal(false);
setEditingCert(null);
toast({ title: "✅ Certification saved" });
},
});
const sendExpiryAlert = async (cert) => {
try {
await base44.integrations.Core.SendEmail({
to: user?.email || "admin@company.com",
subject: `⚠️ Certification Expiring: ${cert.employee_name} - ${cert.certification_type}`,
body: `<h2>Certification Expiring Alert</h2>
<p><strong>Employee:</strong> ${cert.employee_name}</p>
<p><strong>Certification:</strong> ${cert.certification_type}</p>
<p><strong>Expiry Date:</strong> ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}</p>
<p><strong>Days Until Expiry:</strong> ${cert.days_until_expiry} days</p>`
});
toast({ title: "✅ Alert sent" });
} catch (error) {
toast({ title: "Failed to send alert", variant: "destructive" });
}
};
const sendComplianceReport = async (clientEmail) => {
const compliantEmployees = employeeList.filter(e => e.allCurrent);
try {
await base44.integrations.Core.SendEmail({
to: clientEmail,
subject: "Staff Compliance Report",
body: `<h2>Staff Compliance Report</h2>
<p>Generated: ${format(new Date(), 'MMM d, yyyy')}</p>
<p><strong>Total Staff:</strong> ${stats.total}</p>
<p><strong>Fully Compliant:</strong> ${stats.compliant}</p>
<p><strong>Average Compliance:</strong> ${stats.avgCompliance}%</p>
<hr/><h3>Compliant Staff</h3>
<ul>${compliantEmployees.map(e => `<li>${e.employee_name}</li>`).join('')}</ul>`
});
toast({ title: "✅ Report sent" });
setShowReportModal(false);
} catch (error) {
toast({ title: "Failed to send report", variant: "destructive" });
}
};
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<Award className="w-8 h-8" />
<h1 className="text-3xl font-bold">Certification</h1>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<div className="p-4 md:p-6 max-w-[1800px] mx-auto">
{/* Hero Header */}
<div className="relative overflow-hidden bg-gradient-to-r from-[#0A39DF] via-blue-600 to-[#1C323E] rounded-2xl p-6 md:p-8 mb-6 text-white">
<div className="absolute inset-0 opacity-50" style={{ backgroundImage: "url(\"data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E\")" }} />
<div className="relative z-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-12 h-12 bg-white/20 backdrop-blur rounded-xl flex items-center justify-center">
<Award className="w-6 h-6" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold">Certification Hub</h1>
<p className="text-blue-100 text-sm">Track & manage workforce compliance</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="bg-white/20 hover:bg-white/30 text-white border-0" onClick={() => setShowReportModal(true)}>
<Send className="w-4 h-4 mr-2" />Send Report
</Button>
{(isVendor || userRole === "admin") && (
<Button className="bg-white text-blue-600 hover:bg-blue-50" onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-2" />Add Certification
</Button>
)}
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mt-6">
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<User className="w-4 h-4 text-blue-200" />
<span className="text-xs text-blue-200">Total Staff</span>
</div>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-emerald-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<CheckCircle2 className="w-4 h-4 text-emerald-200" />
<span className="text-xs text-emerald-200">Compliant</span>
</div>
<p className="text-2xl font-bold">{stats.compliant}</p>
</div>
<div className="bg-amber-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-amber-200" />
<span className="text-xs text-amber-200">Expiring 30d</span>
</div>
<p className="text-2xl font-bold">{stats.expiring}</p>
</div>
<div className="bg-red-500/30 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4 text-red-200" />
<span className="text-xs text-red-200">Expired</span>
</div>
<p className="text-2xl font-bold">{stats.expired}</p>
</div>
<div className="bg-white/10 backdrop-blur rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-blue-200" />
<span className="text-xs text-blue-200">Avg. Compliance</span>
</div>
<p className="text-2xl font-bold">{stats.avgCompliance}%</p>
</div>
</div>
</div>
</div>
<div className="text-center py-16 bg-white rounded-xl border">
<Award className="w-16 h-16 mx-auto text-slate-300 mb-4" />
<p className="text-slate-600">Certification management coming soon</p>
{/* Filters Bar */}
<Card className="border-0 shadow-lg mb-6 overflow-hidden">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row md:items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11 bg-slate-50 border-slate-200 rounded-xl"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-shrink-0">
<TabsList className="bg-slate-100 p-1 rounded-xl h-11">
<TabsTrigger value="all" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow">
All
</TabsTrigger>
<TabsTrigger value="compliant" className="rounded-lg data-[state=active]:bg-emerald-500 data-[state=active]:text-white">
Compliant
</TabsTrigger>
<TabsTrigger value="expiring" className="rounded-lg data-[state=active]:bg-amber-500 data-[state=active]:text-white">
30 Days
</TabsTrigger>
<TabsTrigger value="expired" className="rounded-lg data-[state=active]:bg-red-500 data-[state=active]:text-white">
Expired
</TabsTrigger>
<TabsTrigger value="incomplete" className="rounded-lg data-[state=active]:bg-slate-600 data-[state=active]:text-white">
Missing
</TabsTrigger>
</TabsList>
</Tabs>
<Select value={certTypeFilter} onValueChange={setCertTypeFilter}>
<SelectTrigger className="w-[180px] h-11 rounded-xl">
<Filter className="w-4 h-4 mr-2 text-slate-400" />
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Background Check">Background Check</SelectItem>
<SelectItem value="RBS">RBS</SelectItem>
<SelectItem value="Food Handler">Food Handler</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Employee Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<AnimatePresence>
{filteredEmployees.length === 0 ? (
<div className="col-span-full">
<Card className="border-0 shadow-lg">
<CardContent className="p-12 text-center">
<Award className="w-16 h-16 mx-auto mb-4 text-slate-200" />
<h3 className="text-lg font-semibold text-slate-700 mb-2">No employees found</h3>
<p className="text-slate-500">Try adjusting your search or filters</p>
</CardContent>
</Card>
</div>
) : (
filteredEmployees.map((emp, idx) => (
<motion.div
key={emp.employee_id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: idx * 0.05 }}
>
<EmployeeCertCard
employee={emp}
onAddCert={(type) => {
setEditingCert({
employee_id: emp.employee_id,
employee_name: emp.employee_name,
vendor_id: emp.vendor_id,
vendor_name: emp.vendor_name,
certification_type: type,
});
setShowAddModal(true);
}}
onEditCert={(cert) => {
setEditingCert(cert);
setShowAddModal(true);
}}
onSendAlert={sendExpiryAlert}
showVendor={isProcurement || userRole === "admin"}
/>
</motion.div>
))
)}
</AnimatePresence>
</div>
{/* Add/Edit Modal */}
<Dialog open={showAddModal} onOpenChange={setShowAddModal}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Award className="w-5 h-5 text-blue-600" />
{editingCert?.id ? 'Update' : 'Add'} Certification
</DialogTitle>
{editingCert?.employee_name && (
<DialogDescription>For: {editingCert.employee_name}</DialogDescription>
)}
</DialogHeader>
<CertificationForm
certification={editingCert}
staff={staff}
onSave={(data) => saveCertMutation.mutate(data)}
onCancel={() => { setShowAddModal(false); setEditingCert(null); }}
isLoading={saveCertMutation.isPending}
/>
</DialogContent>
</Dialog>
{/* Report Modal */}
<Dialog open={showReportModal} onOpenChange={setShowReportModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="w-5 h-5 text-blue-600" />
Send Compliance Report
</DialogTitle>
</DialogHeader>
<ReportForm onSend={sendComplianceReport} onCancel={() => setShowReportModal(false)} stats={stats} />
</DialogContent>
</Dialog>
</div>
</div>
);
}
function EmployeeCertCard({ employee, onAddCert, onEditCert, onSendAlert, showVendor }) {
const emp = employee;
return (
<Card className={`border-0 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden ${
!emp.canWork ? 'ring-2 ring-red-200' : emp.allCurrent ? 'ring-2 ring-emerald-200' : ''
}`}>
<CardContent className="p-0">
{/* Header */}
<div className={`p-4 ${emp.allCurrent ? 'bg-gradient-to-r from-emerald-500 to-emerald-600' : emp.hasExpired ? 'bg-gradient-to-r from-red-500 to-red-600' : emp.hasExpiringSoon ? 'bg-gradient-to-r from-amber-500 to-amber-600' : 'bg-gradient-to-r from-slate-500 to-slate-600'} text-white`}>
<div className="flex items-center gap-3">
<Avatar className="w-12 h-12 border-2 border-white/30">
<AvatarFallback className="bg-white/20 text-white font-bold text-lg">
{emp.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg truncate">{emp.employee_name}</h3>
<p className="text-sm text-white/80 truncate">{emp.position || "Staff Member"}</p>
</div>
<div className="text-right">
<div className={`px-3 py-1 rounded-full text-xs font-bold ${emp.canWork ? 'bg-white/20' : 'bg-white text-red-600'}`}>
{emp.canWork ? "Can Work" : "Cannot Work"}
</div>
</div>
</div>
{/* Compliance Progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-white/80">Compliance Score</span>
<span className="font-bold">{emp.complianceScore}%</span>
</div>
<Progress value={emp.complianceScore} className="h-2 bg-white/20" />
</div>
</div>
{/* Certifications */}
<div className="p-4 space-y-3">
{REQUIRED_CERTIFICATIONS.map(type => {
const cert = emp.certifications[type];
const config = CERT_CONFIG[type];
const Icon = config.icon;
return (
<div
key={type}
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
cert ? (
cert.status === "current" ? `${config.bgColor} ${config.borderColor}` :
cert.status === "expiring_soon" ? "bg-amber-50 border-amber-200" :
"bg-red-50 border-red-200"
) : "bg-slate-50 border-dashed border-slate-300"
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
cert ? (
cert.status === "current" ? `bg-gradient-to-br ${config.color} text-white` :
cert.status === "expiring_soon" ? "bg-amber-500 text-white" :
"bg-red-500 text-white"
) : "bg-slate-200 text-slate-400"
}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm">{type}</p>
{cert ? (
<p className={`text-xs ${cert.status === "current" ? "text-slate-500" : cert.status === "expiring_soon" ? "text-amber-600" : "text-red-600"}`}>
{cert.status === "expired" ? "Expired" : `Expires: ${format(parseISO(cert.expiry_date), 'MMM d, yyyy')}`}
{cert.days_until_expiry !== null && cert.days_until_expiry >= 0 && ` (${cert.days_until_expiry}d)`}
</p>
) : (
<p className="text-xs text-slate-400">Not uploaded</p>
)}
</div>
<div className="flex items-center gap-1">
{cert ? (
<>
{cert.status === "current" && (
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
)}
{cert.status === "expiring_soon" && (
<button
onClick={() => onSendAlert(cert)}
className="p-1.5 rounded-lg bg-amber-100 text-amber-600 hover:bg-amber-200 transition-colors"
title="Send reminder"
>
<Bell className="w-4 h-4" />
</button>
)}
{cert.status === "expired" && (
<XCircle className="w-5 h-5 text-red-500" />
)}
<button
onClick={() => onEditCert(cert)}
className="p-1.5 rounded-lg bg-slate-100 text-slate-600 hover:bg-slate-200 transition-colors"
title="Edit"
>
<Edit2 className="w-4 h-4" />
</button>
</>
) : (
<button
onClick={() => onAddCert(type)}
className="p-1.5 rounded-lg bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
title="Add certification"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
);
})}
</div>
{/* Footer */}
{showVendor && emp.vendor_name && (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 text-xs text-slate-500">
<Building2 className="w-3 h-3" />
<span>{emp.vendor_name}</span>
</div>
</div>
)}
</CardContent>
</Card>
);
}
function CertificationForm({ certification, staff, onSave, onCancel, isLoading }) {
const [formData, setFormData] = useState({
employee_id: certification?.employee_id || "",
employee_name: certification?.employee_name || "",
vendor_id: certification?.vendor_id || "",
vendor_name: certification?.vendor_name || "",
certification_type: certification?.certification_type || "",
issue_date: certification?.issue_date || "",
expiry_date: certification?.expiry_date || "",
issuer: certification?.issuer || "",
certificate_number: certification?.certificate_number || "",
notes: certification?.notes || "",
...certification,
});
const handleSubmit = (e) => {
e.preventDefault();
onSave(formData);
};
const handleStaffSelect = (staffId) => {
const selectedStaff = staff.find(s => s.id === staffId);
if (selectedStaff) {
setFormData(prev => ({
...prev,
employee_id: staffId,
employee_name: selectedStaff.employee_name,
vendor_id: selectedStaff.vendor_id,
vendor_name: selectedStaff.vendor_name,
}));
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{!certification?.employee_id && (
<div>
<Label className="text-sm font-medium">Employee *</Label>
<Select value={formData.employee_id} onValueChange={handleStaffSelect}>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="Select employee" />
</SelectTrigger>
<SelectContent>
{staff.map(s => (
<SelectItem key={s.id} value={s.id}>{s.employee_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label className="text-sm font-medium">Certification Type *</Label>
<Select
value={formData.certification_type}
onValueChange={(v) => setFormData(prev => ({ ...prev, certification_type: v }))}
disabled={!!certification?.certification_type}
>
<SelectTrigger className="mt-1.5">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{REQUIRED_CERTIFICATIONS.map(type => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
{React.createElement(CERT_CONFIG[type].icon, { className: "w-4 h-4" })}
{type}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">Issue Date</Label>
<Input
type="date"
value={formData.issue_date}
onChange={(e) => setFormData(prev => ({ ...prev, issue_date: e.target.value }))}
className="mt-1.5"
/>
</div>
<div>
<Label className="text-sm font-medium">Expiry Date *</Label>
<Input
type="date"
value={formData.expiry_date}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
className="mt-1.5"
required
/>
</div>
</div>
<div>
<Label className="text-sm font-medium">Issuing Authority</Label>
<Input
value={formData.issuer}
onChange={(e) => setFormData(prev => ({ ...prev, issuer: e.target.value }))}
placeholder="e.g., California ABC"
className="mt-1.5"
/>
</div>
<div>
<Label className="text-sm font-medium">Certificate Number</Label>
<Input
value={formData.certificate_number}
onChange={(e) => setFormData(prev => ({ ...prev, certificate_number: e.target.value }))}
placeholder="Certificate ID"
className="mt-1.5"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={isLoading} className="bg-blue-600 hover:bg-blue-700">
{isLoading ? "Saving..." : "Save Certification"}
</Button>
</div>
</form>
);
}
function ReportForm({ onSend, onCancel, stats }) {
const [email, setEmail] = useState("");
return (
<div className="space-y-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-slate-50 rounded-xl border border-blue-100">
<h4 className="font-semibold text-slate-900 mb-3 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-600" />
Report Preview
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="bg-white p-3 rounded-lg">
<span className="text-slate-500 text-xs">Total Staff</span>
<p className="font-bold text-lg text-slate-900">{stats.total}</p>
</div>
<div className="bg-emerald-50 p-3 rounded-lg">
<span className="text-emerald-600 text-xs">Compliant</span>
<p className="font-bold text-lg text-emerald-700">{stats.compliant}</p>
</div>
<div className="bg-amber-50 p-3 rounded-lg">
<span className="text-amber-600 text-xs">Expiring Soon</span>
<p className="font-bold text-lg text-amber-700">{stats.expiring}</p>
</div>
<div className="bg-red-50 p-3 rounded-lg">
<span className="text-red-600 text-xs">Expired</span>
<p className="font-bold text-lg text-red-700">{stats.expired}</p>
</div>
</div>
</div>
<div>
<Label className="text-sm font-medium">Recipient Email *</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="client@company.com"
className="mt-1.5"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button onClick={() => onSend(email)} disabled={!email} className="bg-blue-600 hover:bg-blue-700">
<Send className="w-4 h-4 mr-2" />Send Report
</Button>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
@@ -21,17 +21,17 @@ export default function CreateEvent() {
const { data: currentUser } = useQuery({
queryKey: ['current-user-create-event'],
queryFn: () => krowSDK.auth.me(),
queryFn: () => base44.auth.me(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-for-conflict-check'],
queryFn: () => krowSDK.entities.Event.list(),
queryFn: () => base44.entities.Event.list(),
initialData: [],
});
const createEventMutation = useMutation({
mutationFn: (eventData) => krowSDK.entities.Event.create(eventData),
mutationFn: (eventData) => base44.entities.Event.create(eventData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });

View File

@@ -1,5 +1,4 @@
import React, { useState } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
@@ -13,6 +12,7 @@ import StatsCard from "@/components/staff/StatsCard";
import EcosystemWheel from "@/components/dashboard/EcosystemWheel";
import QuickMetrics from "@/components/dashboard/QuickMetrics";
import PageHeader from "@/components/common/PageHeader";
import DashboardCustomizer from "@/components/dashboard/DashboardCustomizer";
import { format, parseISO, isValid, isSameDay, startOfDay } from "date-fns";
const safeParseDate = (dateString) => {
@@ -108,6 +108,12 @@ const getAssignmentStatus = (event) => {
export default function Dashboard() {
const navigate = useNavigate();
const [selectedLayer, setSelectedLayer] = useState(null);
const [visibleWidgets, setVisibleWidgets] = useState([]);
const { data: user } = useQuery({
queryKey: ['current-user-admin-dashboard'],
queryFn: () => base44.auth.me(),
});
const { data: staff, isLoading: loadingStaff } = useQuery({
queryKey: ['staff'],
@@ -121,6 +127,30 @@ export default function Dashboard() {
initialData: [],
});
// Define available widgets for this dashboard
const availableWidgets = useMemo(() => [
{ id: 'global_metrics', title: 'Global Metrics', description: 'Fill rate, spend, score & active events', category: 'Overview', categoryColor: 'bg-blue-100 text-blue-700' },
{ id: 'todays_orders', title: "Today's Orders", description: 'Orders scheduled for today', category: 'Operations', categoryColor: 'bg-emerald-100 text-emerald-700' },
{ id: 'ecosystem_map', title: 'Ecosystem Map', description: 'Interactive connection map', category: 'Overview', categoryColor: 'bg-purple-100 text-purple-700' },
{ id: 'quick_access', title: 'Quick Access Cards', description: 'Procurement, Operator & Vendor dashboards', category: 'Navigation', categoryColor: 'bg-amber-100 text-amber-700' },
{ id: 'workforce_overview', title: 'Workforce Overview', description: 'Recent staff and workers', category: 'Workforce', categoryColor: 'bg-pink-100 text-pink-700' },
], []);
// Initialize visible widgets from user preferences or defaults
useEffect(() => {
const savedLayout = user?.dashboard_layout_admin;
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
const orderedWidgets = savedLayout.widgets
.map(id => availableWidgets.find(w => w.id === id))
.filter(Boolean);
setVisibleWidgets(orderedWidgets);
} else {
setVisibleWidgets(availableWidgets);
}
}, [user, availableWidgets]);
const isWidgetVisible = (widgetId) => visibleWidgets.some(w => w.id === widgetId);
// Filter events for today only
const today = startOfDay(new Date());
const todaysEvents = events.filter(event => {
@@ -198,6 +228,13 @@ export default function Dashboard() {
subtitle="Your Complete Workforce Management Ecosystem"
actions={
<>
<DashboardCustomizer
user={user}
availableWidgets={availableWidgets}
currentLayout={visibleWidgets}
onLayoutChange={setVisibleWidgets}
dashboardType="admin"
/>
<Button variant="outline" className="border-slate-300 hover:bg-slate-100">
<BarChart3 className="w-4 h-4 mr-2" />
Reports
@@ -213,281 +250,291 @@ export default function Dashboard() {
/>
{/* Global Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Fill Rate"
value={`${totalFillRate}%`}
icon={Target}
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
change="+2.5% this month"
/>
<StatsCard
title="Total Spend"
value={`$${totalSpend}M`}
icon={DollarSign}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
change="+$180K this month"
/>
<StatsCard
title="Overall Score"
value={overallScore}
icon={Award}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatsCard
title="Active Events"
value={activeEvents}
icon={Calendar}
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
change={`${completionRate}% completion rate`}
/>
</div>
{isWidgetVisible('global_metrics') && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Fill Rate"
value={`${totalFillRate}%`}
icon={Target}
gradient="bg-gradient-to-br from-[#0A39DF] to-[#1C323E]"
change="+2.5% this month"
/>
<StatsCard
title="Total Spend"
value={`$${totalSpend}M`}
icon={DollarSign}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-700"
change="+$180K this month"
/>
<StatsCard
title="Overall Score"
value={overallScore}
icon={Award}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatsCard
title="Active Events"
value={activeEvents}
icon={Calendar}
gradient="bg-gradient-to-br from-purple-500 to-purple-700"
change={`${completionRate}% completion rate`}
/>
</div>
)}
{/* Today's Orders Section */}
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
{isWidgetVisible('todays_orders') && (
<Card className="mb-8 border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Calendar className="w-6 h-6 text-[#0A39DF]" />
Today's Orders - {format(today, 'EEEE, MMMM d, yyyy')}
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Orders scheduled for today only</p>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
<Link to={createPageUrl("Events")}>
<Button variant="outline" className="border-slate-300">
View All Orders
</Button>
</Link>
</div>
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
</CardHeader>
<CardContent className="p-0">
{todaysEvents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<Calendar className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No orders scheduled for today</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b">
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide h-10">BUSINESS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">HUB</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">EVENT</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">DATE & TIME</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide">STATUS</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">REQUESTED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ASSIGNED</TableHead>
<TableHead className="font-semibold text-slate-600 uppercase text-[10px] tracking-wide text-center">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todaysEvents.map((event) => {
const assignmentStatus = getAssignmentStatus(event);
const eventTimes = getEventTimes(event);
const eventDate = safeParseDate(event.date);
const dayOfWeek = eventDate ? format(eventDate, 'EEEE') : '';
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
return (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors border-b">
<TableCell className="py-3">
<p className="text-sm text-slate-700 font-medium">{event.business_name || "—"}</p>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center gap-1.5 text-sm text-slate-500">
<MapPin className="w-3.5 h-3.5" />
{event.hub || event.event_location || "Main Hub"}
</div>
</div>
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</TableCell>
<TableCell className="py-3">
<p className="font-semibold text-slate-900 text-sm">{event.event_name}</p>
</TableCell>
<TableCell className="py-3">
<div className="space-y-0.5">
<p className="text-sm text-slate-900 font-semibold">{eventDate ? format(eventDate, 'MM.dd.yyyy') : '-'}</p>
<p className="text-xs text-slate-500">{dayOfWeek}</p>
<div className="flex items-center gap-1 text-xs text-slate-600 mt-1">
<Clock className="w-3 h-3" />
<span>{eventTimes.startTime} - {eventTimes.endTime}</span>
</div>
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
</TableCell>
<TableCell className="py-3">
{getStatusBadge(event)}
</TableCell>
<TableCell className="text-center py-3">
<span className="font-semibold text-slate-700 text-sm">{event.requested || 0}</span>
</TableCell>
<TableCell className="text-center py-3">
<div className="flex flex-col items-center gap-1">
<div className={`w-10 h-10 rounded-full ${assignmentStatus.color} flex items-center justify-center font-bold text-sm`}>
{assignmentStatus.text}
</div>
<span className="text-[10px] text-slate-500 font-medium">{assignmentStatus.percent}</span>
</div>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
onClick={() => navigate(createPageUrl(`EventDetail?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
title="View"
>
<FileText className="w-4 h-4 text-blue-600" />
<Eye className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="Edit"
>
<Edit className="w-4 h-4" />
</Button>
{event.invoice_id && (
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`Invoices?id=${event.invoice_id}`))}
className="hover:bg-slate-100 h-8 w-8"
title="View Invoice"
>
<FileText className="w-4 h-4 text-blue-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)}
{/* Ecosystem Puzzle */}
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Target className="w-6 h-6 text-[#0A39DF]" />
Ecosystem Connection Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects Hover to see metrics Click to explore</p>
</CardHeader>
<CardContent className="p-8">
<EcosystemWheel
layers={ecosystemLayers}
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
selectedLayer={selectedLayer}
onLayerHover={setSelectedLayer}
/>
</CardContent>
</Card>
{isWidgetVisible('ecosystem_map') && (
<Card className="mb-8 border-slate-200 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Target className="w-6 h-6 text-[#0A39DF]" />
Ecosystem Connection Map
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Interactive puzzle showing how each layer connects • Hover to see metrics • Click to explore</p>
</CardHeader>
<CardContent className="p-8">
<EcosystemWheel
layers={ecosystemLayers}
onLayerClick={(layer) => navigate(createPageUrl(layer.route))}
selectedLayer={selectedLayer}
onLayerHover={setSelectedLayer}
/>
</CardContent>
</Card>
)}
{/* Quick Access Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<QuickMetrics
title="Procurement & Vendor Intelligence"
description="Vendor efficiency, spend analysis, compliance tracking"
icon={Shield}
metrics={[
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
{ label: "Compliance", value: "98%", color: "text-blue-600" },
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
]}
route="ProcurementDashboard"
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
/>
{isWidgetVisible('quick_access') && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<QuickMetrics
title="Procurement & Vendor Intelligence"
description="Vendor efficiency, spend analysis, compliance tracking"
icon={Shield}
metrics={[
{ label: "Vendor Score", value: "A+", color: "text-green-600" },
{ label: "Compliance", value: "98%", color: "text-blue-600" },
{ label: "ESG Rating", value: "B+", color: "text-emerald-600" }
]}
route="ProcurementDashboard"
gradient="from-[#0A39DF]/10 to-[#1C323E]/10"
/>
<QuickMetrics
title="Operator & Sector Dashboard"
description="Live coverage, demand forecast, incident tracking"
icon={MapPin}
metrics={[
{ label: "Coverage", value: "94%", color: "text-green-600" },
{ label: "Incidents", value: "2", color: "text-yellow-600" },
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
]}
route="OperatorDashboard"
gradient="from-emerald-500/10 to-emerald-700/10"
/>
<QuickMetrics
title="Operator & Sector Dashboard"
description="Live coverage, demand forecast, incident tracking"
icon={MapPin}
metrics={[
{ label: "Coverage", value: "94%", color: "text-green-600" },
{ label: "Incidents", value: "2", color: "text-yellow-600" },
{ label: "Forecast Accuracy", value: "91%", color: "text-blue-600" }
]}
route="OperatorDashboard"
gradient="from-emerald-500/10 to-emerald-700/10"
/>
<QuickMetrics
title="Vendor Dashboard"
description="Orders, invoices, workforce pulse, KROW score"
icon={Award}
metrics={[
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
{ label: "Attendance", value: "95%", color: "text-blue-600" },
{ label: "Training", value: "92%", color: "text-purple-600" }
]}
route="VendorDashboard"
gradient="from-amber-500/10 to-amber-700/10"
/>
</div>
<QuickMetrics
title="Vendor Dashboard"
description="Orders, invoices, workforce pulse, KROW score"
icon={Award}
metrics={[
{ label: "Fill Rate", value: "97%", color: "text-green-600" },
{ label: "Attendance", value: "95%", color: "text-blue-600" },
{ label: "Training", value: "92%", color: "text-purple-600" }
]}
route="VendorDashboard"
gradient="from-amber-500/10 to-amber-700/10"
/>
</div>
)}
{/* Workforce Section */}
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Users className="w-6 h-6 text-[#0A39DF]" />
Workforce Overview
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
</div>
<div className="flex gap-2">
<Link to={createPageUrl("WorkforceDashboard")}>
<Button variant="outline" className="border-slate-300">
View Workforce App
</Button>
</Link>
<Link to={createPageUrl("StaffDirectory")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
View All Staff
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{recentStaff.slice(0, 3).map((member) => (
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
{member.initial || member.employee_name?.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<p className="text-sm text-slate-500">{member.position}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Rating:</span>
<span className="font-semibold">{member.rating || 0}/5 </span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Cancellations:</span>
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
</div>
</div>
{isWidgetVisible('workforce_overview') && (
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-[#1C323E] flex items-center gap-2">
<Users className="w-6 h-6 text-[#0A39DF]" />
Workforce Overview
</CardTitle>
<p className="text-sm text-slate-500 mt-1">Recent additions and active workers</p>
</div>
))}
</div>
</CardContent>
</Card>
<div className="flex gap-2">
<Link to={createPageUrl("WorkforceDashboard")}>
<Button variant="outline" className="border-slate-300">
View Workforce App
</Button>
</Link>
<Link to={createPageUrl("StaffDirectory")}>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
View All Staff
</Button>
</Link>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{recentStaff.slice(0, 3).map((member) => (
<div key={member.id} className="p-4 rounded-lg border border-slate-200 hover:border-[#0A39DF] hover:shadow-md transition-all">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold">
{member.initial || member.employee_name?.charAt(0)}
</div>
<div>
<h4 className="font-semibold text-[#1C323E]">{member.employee_name}</h4>
<p className="text-sm text-slate-500">{member.position}</p>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Rating:</span>
<span className="font-semibold">{member.rating || 0}/5 </span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Coverage:</span>
<span className="font-semibold text-green-600">{member.shift_coverage_percentage || 0}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Cancellations:</span>
<span className="font-semibold text-red-600">{member.cancellation_count || 0}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
);

View File

@@ -0,0 +1,703 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
FileText, Search, Plus, Clock, CheckCircle2, XCircle, AlertTriangle,
Upload, Eye, Download, Activity, Filter, Users, ChevronRight
} from "lucide-react";
import { format, differenceInDays, parseISO } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
const DOCUMENT_TYPES = [
"W-4 Form",
"I-9 Form",
"State Tax Form",
"Direct Deposit",
"ID Copy",
"SSN Card",
"Work Permit"
];
const STATUS_CONFIG = {
uploaded: { color: "bg-cyan-400", icon: FileText, label: "Uploaded" },
pending: { color: "bg-amber-300", icon: Clock, label: "Pending" },
expiring: { color: "bg-yellow-400", icon: Clock, label: "Expiring" },
expired: { color: "bg-red-400", icon: XCircle, label: "Expired" },
rejected: { color: "bg-red-500", icon: XCircle, label: "Rejected" },
missing: { color: "bg-slate-200", icon: Plus, label: "Missing" },
};
export default function EmployeeDocuments() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [showUploadModal, setShowUploadModal] = useState(false);
const [selectedCell, setSelectedCell] = useState(null);
const [showActivityPanel, setShowActivityPanel] = useState(false);
const [docTypeFilter, setDocTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const { data: user } = useQuery({
queryKey: ['current-user-docs'],
queryFn: () => base44.auth.me(),
});
const { data: documents = [] } = useQuery({
queryKey: ['employee-documents'],
queryFn: () => base44.entities.EmployeeDocument.list(),
initialData: [],
});
const { data: staff = [] } = useQuery({
queryKey: ['staff-for-docs'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
const isVendor = userRole === "vendor";
// Filter staff by vendor
const filteredStaff = useMemo(() => {
let result = staff;
if (isVendor && user?.vendor_id) {
result = result.filter(s => s.vendor_id === user.vendor_id);
}
if (searchTerm) {
result = result.filter(s =>
s.employee_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return result;
}, [staff, isVendor, user, searchTerm]);
// Build document matrix
const documentMatrix = useMemo(() => {
const matrix = {};
filteredStaff.forEach(emp => {
matrix[emp.id] = {
employee: emp,
documents: {},
completionRate: 0,
};
DOCUMENT_TYPES.forEach(type => {
matrix[emp.id].documents[type] = null;
});
});
documents.forEach(doc => {
if (matrix[doc.employee_id]) {
// Calculate status based on expiry
let status = doc.status || "uploaded";
if (doc.expiry_date) {
const days = differenceInDays(parseISO(doc.expiry_date), new Date());
if (days < 0) status = "expired";
else if (days <= 30) status = "expiring";
}
matrix[doc.employee_id].documents[doc.document_type] = { ...doc, status };
}
});
// Calculate completion rates
Object.values(matrix).forEach(row => {
const uploaded = Object.values(row.documents).filter(d => d && d.status === "uploaded").length;
row.completionRate = Math.round((uploaded / DOCUMENT_TYPES.length) * 100);
});
return matrix;
}, [filteredStaff, documents]);
// Stats
const stats = useMemo(() => {
let total = 0, uploaded = 0, pending = 0, expiring = 0, expired = 0;
Object.values(documentMatrix).forEach(row => {
Object.values(row.documents).forEach(doc => {
total++;
if (!doc) return;
if (doc.status === "uploaded") uploaded++;
else if (doc.status === "pending") pending++;
else if (doc.status === "expiring") expiring++;
else if (doc.status === "expired") expired++;
});
});
const missing = total - uploaded - pending - expiring - expired;
return { total, uploaded, pending, expiring, expired, missing };
}, [documentMatrix]);
// Save document
const saveMutation = useMutation({
mutationFn: async (data) => {
if (data.id) {
return base44.entities.EmployeeDocument.update(data.id, data);
}
return base44.entities.EmployeeDocument.create(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee-documents'] });
setShowUploadModal(false);
setSelectedCell(null);
toast({ title: "✅ Document saved" });
},
});
const handleCellClick = (employeeId, docType, existingDoc) => {
const emp = documentMatrix[employeeId]?.employee;
setSelectedCell({
employee_id: employeeId,
employee_name: emp?.employee_name,
vendor_id: emp?.vendor_id,
vendor_name: emp?.vendor_name,
document_type: docType,
...existingDoc,
});
setShowUploadModal(true);
};
const renderCell = (doc, employeeId, docType) => {
const status = doc?.status || "missing";
const config = STATUS_CONFIG[status];
const daysUntilExpiry = doc?.expiry_date ? differenceInDays(parseISO(doc.expiry_date), new Date()) : null;
const isExpirableDoc = docType === "Work Permit" || docType === "ID Copy";
return (
<button
onClick={() => handleCellClick(employeeId, docType, doc)}
className={`w-full h-14 rounded-lg flex flex-col items-center justify-center transition-all hover:scale-105 hover:shadow-md ${config.color} relative group`}
>
{status === "missing" ? (
<Plus className="w-5 h-5 text-slate-400" />
) : status === "uploaded" ? (
<>
<div className="flex items-center gap-1">
<FileText className="w-4 h-4 text-white" />
<span className="text-[9px] text-white font-bold bg-cyan-600 px-1 rounded"></span>
</div>
{isExpirableDoc && doc?.expiry_date && (
<span className="text-[9px] text-white/90 mt-0.5 font-medium">
{format(parseISO(doc.expiry_date), 'MM/dd/yy')}
</span>
)}
</>
) : status === "expiring" ? (
<>
<Clock className="w-4 h-4 text-amber-700" />
<span className="text-[9px] text-amber-800 mt-0.5 font-bold">
{daysUntilExpiry}d left
</span>
</>
) : status === "expired" ? (
<>
<XCircle className="w-4 h-4 text-white" />
<span className="text-[9px] text-white/90 mt-0.5 font-medium">Expired</span>
</>
) : status === "pending" ? (
<Clock className="w-5 h-5 text-amber-700" />
) : (
<config.icon className="w-5 h-5" />
)}
{/* Hover tooltip for expirable docs */}
{isExpirableDoc && doc?.expiry_date && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-slate-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
Expires: {format(parseISO(doc.expiry_date), 'MMM d, yyyy')}
</div>
)}
</button>
);
};
// Calculate column completion percentages
const columnStats = useMemo(() => {
const stats = {};
DOCUMENT_TYPES.forEach(type => {
const total = Object.keys(documentMatrix).length;
const uploaded = Object.values(documentMatrix).filter(
row => row.documents[type]?.status === "uploaded"
).length;
stats[type] = total > 0 ? Math.round((uploaded / total) * 100) : 0;
});
return stats;
}, [documentMatrix]);
// Filter matrix by status and doc type
const filteredMatrix = useMemo(() => {
let result = { ...documentMatrix };
// Filter by status
if (statusFilter !== "all") {
result = Object.fromEntries(
Object.entries(result).filter(([empId, row]) => {
const docs = Object.values(row.documents);
if (statusFilter === "uploaded") return docs.some(d => d?.status === "uploaded");
if (statusFilter === "expiring") return docs.some(d => d?.status === "expiring");
if (statusFilter === "expired") return docs.some(d => d?.status === "expired");
if (statusFilter === "missing") return docs.some(d => !d);
if (statusFilter === "pending") return docs.some(d => d?.status === "pending");
return true;
})
);
}
return result;
}, [documentMatrix, statusFilter, docTypeFilter]);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-slate-100 p-4 md:p-6">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Employee Documents</h1>
<p className="text-sm text-slate-500">Track and manage all required documents</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex -space-x-2">
{filteredStaff.slice(0, 3).map((emp, i) => (
<Avatar key={emp.id} className="w-8 h-8 border-2 border-white">
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white text-xs">
{emp.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
))}
{filteredStaff.length > 3 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
+{filteredStaff.length - 3}
</div>
)}
</div>
<Button
variant="outline"
className={`gap-2 ${showActivityPanel ? 'bg-orange-100 border-orange-300 text-orange-700' : ''}`}
onClick={() => setShowActivityPanel(!showActivityPanel)}
>
<Activity className="w-4 h-4" />
Activity
</Button>
</div>
</div>
{/* Stats Bar - Clickable */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 mb-6">
<Card
className={`border-0 shadow-sm bg-white cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'all' ? 'ring-2 ring-slate-400' : ''}`}
onClick={() => setStatusFilter('all')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center">
<Users className="w-4 h-4 text-slate-600" />
</div>
<div>
<p className="text-lg font-bold text-slate-900">{filteredStaff.length}</p>
<p className="text-[10px] text-slate-500">Employees</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-cyan-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'uploaded' ? 'ring-2 ring-cyan-400' : ''}`}
onClick={() => setStatusFilter('uploaded')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-cyan-400 rounded-lg flex items-center justify-center">
<CheckCircle2 className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-cyan-700">{stats.uploaded}</p>
<p className="text-[10px] text-cyan-600">Uploaded</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-amber-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'pending' ? 'ring-2 ring-amber-400' : ''}`}
onClick={() => setStatusFilter('pending')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-amber-400 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-amber-700">{stats.pending}</p>
<p className="text-[10px] text-amber-600">Pending</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-yellow-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expiring' ? 'ring-2 ring-yellow-400' : ''}`}
onClick={() => setStatusFilter('expiring')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-yellow-400 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-yellow-700">{stats.expiring}</p>
<p className="text-[10px] text-yellow-600">Expiring</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-red-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'expired' ? 'ring-2 ring-red-400' : ''}`}
onClick={() => setStatusFilter('expired')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-red-400 rounded-lg flex items-center justify-center">
<XCircle className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-lg font-bold text-red-700">{stats.expired}</p>
<p className="text-[10px] text-red-600">Expired</p>
</div>
</div>
</CardContent>
</Card>
<Card
className={`border-0 shadow-sm bg-slate-50 cursor-pointer transition-all hover:shadow-md hover:scale-[1.02] ${statusFilter === 'missing' ? 'ring-2 ring-slate-400' : ''}`}
onClick={() => setStatusFilter('missing')}
>
<CardContent className="p-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-slate-300 rounded-lg flex items-center justify-center">
<Plus className="w-4 h-4 text-slate-600" />
</div>
<div>
<p className="text-lg font-bold text-slate-700">{stats.missing}</p>
<p className="text-[10px] text-slate-500">Missing</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Document Matrix */}
<Card className="border-0 shadow-xl rounded-2xl overflow-hidden">
<CardContent className="p-0">
{/* Search & Filter Bar */}
<div className="p-4 border-b border-slate-100 bg-white flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-slate-50 border-0 h-10"
/>
</div>
{/* Document Type Filter */}
<Select value={docTypeFilter} onValueChange={setDocTypeFilter}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Doc Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Documents</SelectItem>
{DOCUMENT_TYPES.map(type => (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<FileText className="w-3 h-3 text-blue-500" />
{type}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px] h-10">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="uploaded">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-cyan-400" />
Uploaded
</div>
</SelectItem>
<SelectItem value="expiring">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-yellow-400" />
Expiring Soon
</div>
</SelectItem>
<SelectItem value="expired">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-400" />
Expired
</div>
</SelectItem>
<SelectItem value="pending">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-400" />
Pending
</div>
</SelectItem>
<SelectItem value="missing">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-300" />
Missing
</div>
</SelectItem>
</SelectContent>
</Select>
{(statusFilter !== "all" || docTypeFilter !== "all") && (
<Button
variant="ghost"
size="sm"
onClick={() => { setStatusFilter("all"); setDocTypeFilter("all"); }}
className="text-slate-500 hover:text-slate-700"
>
Clear filters
</Button>
)}
</div>
{/* Matrix Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50">
<th className="text-left p-4 font-semibold text-slate-700 min-w-[200px] sticky left-0 bg-slate-50 z-10">
Users
</th>
{DOCUMENT_TYPES.map(type => (
<th key={type} className="p-4 min-w-[130px]">
<div className="text-center">
<p className="font-semibold text-slate-700 text-sm mb-2">{type}</p>
<div className="flex gap-0.5 justify-center">
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 25 ? 'bg-cyan-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 50 ? 'bg-amber-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 75 ? 'bg-red-400' : 'bg-slate-200'}`} />
<div className={`h-1 w-6 rounded-full ${columnStats[type] >= 100 ? 'bg-green-400' : 'bg-slate-200'}`} />
</div>
</div>
</th>
))}
<th className="p-4 w-12">
<button className="w-8 h-8 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transition-colors">
<Plus className="w-4 h-4" />
</button>
</th>
</tr>
</thead>
<tbody>
{Object.entries(filteredMatrix).map(([empId, row], idx) => (
<tr key={empId} className={`border-t border-slate-100 ${idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'}`}>
<td className="p-4 sticky left-0 bg-inherit z-10">
<div className="flex items-center gap-3">
<Avatar className="w-10 h-10 border-2 border-white shadow">
<AvatarFallback className="bg-gradient-to-br from-blue-400 to-purple-500 text-white font-bold">
{row.employee.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 truncate">{row.employee.employee_name}</p>
<div className="flex items-center gap-2">
<Progress value={row.completionRate} className="h-1.5 w-16" />
<span className="text-xs text-slate-500">{row.completionRate}%</span>
</div>
</div>
</div>
</td>
{DOCUMENT_TYPES.map(type => (
<td key={type} className={`p-2 ${docTypeFilter !== "all" && docTypeFilter !== type ? 'opacity-30' : ''}`}>
{renderCell(row.documents[type], empId, type)}
</td>
))}
<td className="p-2">
{row.completionRate === 100 && (
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-green-600" />
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredStaff.length === 0 && (
<div className="p-12 text-center">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-200" />
<p className="text-slate-500">No employees found</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-100 bg-slate-50 text-center">
<p className="text-sm text-slate-500">
to make sure everything is always up to date
</p>
</div>
</CardContent>
</Card>
{/* Upload Modal */}
<Dialog open={showUploadModal} onOpenChange={setShowUploadModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
{selectedCell?.id ? 'Update' : 'Upload'} Document
</DialogTitle>
</DialogHeader>
{selectedCell && (
<DocumentUploadForm
data={selectedCell}
onSave={(data) => saveMutation.mutate(data)}
onCancel={() => { setShowUploadModal(false); setSelectedCell(null); }}
isLoading={saveMutation.isPending}
/>
)}
</DialogContent>
</Dialog>
{/* Activity Panel */}
{showActivityPanel && (
<div className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl border-l border-slate-200 z-50 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="font-bold text-lg">Recent Activity</h3>
<Button variant="ghost" size="sm" onClick={() => setShowActivityPanel(false)}>
<XCircle className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
{[1,2,3,4,5].map(i => (
<div key={i} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
<div className="w-8 h-8 rounded-full bg-cyan-400 flex items-center justify-center">
<Upload className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-sm font-medium text-slate-900">Document uploaded</p>
<p className="text-xs text-slate-500">W-4 Form 2 hours ago</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function DocumentUploadForm({ data, onSave, onCancel, isLoading }) {
const [formData, setFormData] = useState({
employee_id: data.employee_id || "",
employee_name: data.employee_name || "",
vendor_id: data.vendor_id || "",
vendor_name: data.vendor_name || "",
document_type: data.document_type || "",
status: data.status || "uploaded",
expiry_date: data.expiry_date || "",
document_url: data.document_url || "",
notes: data.notes || "",
...data,
});
const [uploading, setUploading] = useState(false);
const handleFileUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const result = await base44.integrations.Core.UploadFile({ file });
setFormData(prev => ({ ...prev, document_url: result.file_url, status: "uploaded" }));
} catch (error) {
console.error("Upload failed:", error);
}
setUploading(false);
};
return (
<div className="space-y-4">
<div className="p-4 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">Employee</p>
<p className="font-semibold text-slate-900">{formData.employee_name}</p>
<p className="text-sm text-blue-600 mt-1">{formData.document_type}</p>
</div>
<div>
<Label className="text-sm font-medium">Upload Document</Label>
<div className="mt-2 border-2 border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-400 transition-colors">
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileUpload}
className="hidden"
id="doc-upload"
/>
<label htmlFor="doc-upload" className="cursor-pointer">
{uploading ? (
<div className="animate-pulse">Uploading...</div>
) : formData.document_url ? (
<div className="flex items-center justify-center gap-2 text-green-600">
<CheckCircle2 className="w-6 h-6" />
<span>Document uploaded</span>
</div>
) : (
<div>
<Upload className="w-8 h-8 mx-auto text-slate-400 mb-2" />
<p className="text-sm text-slate-600">Click to upload</p>
<p className="text-xs text-slate-400">PDF, JPG, PNG</p>
</div>
)}
</label>
</div>
</div>
<div>
<Label className="text-sm font-medium">Expiry Date (if applicable)</Label>
<Input
type="date"
value={formData.expiry_date}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
className="mt-1.5"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => onSave(formData)}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? "Saving..." : "Save Document"}
</Button>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ import PageHeader from "@/components/common/PageHeader";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import AutoInvoiceGenerator from "@/components/invoices/AutoInvoiceGenerator";
import CreateInvoiceModal from "@/components/invoices/CreateInvoiceModal";
const statusColors = {
'Draft': 'bg-slate-100 text-slate-600 font-medium',
@@ -34,7 +34,7 @@ export default function Invoices() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [showCreateModal, setShowCreateModal] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-invoices'],
@@ -212,335 +212,327 @@ export default function Invoices() {
<>
<AutoInvoiceGenerator />
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-[1600px] mx-auto">
<PageHeader
title="Invoices"
subtitle={`${filteredInvoices.length} invoices • $${metrics.all.toLocaleString()} total`}
actions={
userRole === "vendor" && (
<Button onClick={() => setShowCreateModal(true)} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Plus className="w-5 h-5 mr-2" />
Create Invoice
</Button>
)
}
/>
{/* Alert Banners */}
{metrics.disputed > 0 && (
<div className="mb-6 p-4 bg-red-50 border-l-4 border-red-500 rounded-lg flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-red-600" />
<div>
<p className="font-semibold text-red-900">Disputed Invoices Require Attention</p>
<p className="text-sm text-red-700">{getStatusCount("Disputed")} invoices are currently disputed</p>
</div>
</div>
)}
{metrics.overdue > 0 && userRole === "client" && (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-center gap-3">
<Clock className="w-5 h-5 text-amber-600" />
<div>
<p className="font-semibold text-amber-900">Overdue Payments</p>
<p className="text-sm text-amber-700">${metrics.overdue.toLocaleString()} in overdue invoices</p>
</div>
</div>
)}
{/* Status Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mb-6">
<TabsList className="bg-slate-100 border border-slate-200 h-auto p-1.5 flex-wrap gap-1">
<TabsTrigger
value="all"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<FileText className="w-4 h-4 mr-2" />
All
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("all")}</Badge>
</TabsTrigger>
<TabsTrigger
value="pending"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<Clock className="w-4 h-4 mr-2" />
Pending
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Pending Review")}</Badge>
</TabsTrigger>
<TabsTrigger
value="approved"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approved
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Approved")}</Badge>
</TabsTrigger>
<TabsTrigger
value="disputed"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Disputed
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Disputed")}</Badge>
</TabsTrigger>
<TabsTrigger
value="overdue"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Overdue
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Overdue")}</Badge>
</TabsTrigger>
<TabsTrigger
value="paid"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Paid
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Paid")}</Badge>
</TabsTrigger>
<TabsTrigger
value="reconciled"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white bg-white text-slate-700 hover:bg-slate-50 transition-all rounded-md px-3 py-2"
>
<CheckCircle className="w-4 h-4 mr-2" />
Reconciled
<Badge className="ml-2 bg-yellow-400 text-yellow-900 hover:bg-yellow-400 border-0 font-bold">{getStatusCount("Reconciled")}</Badge>
</TabsTrigger>
</TabsList>
</Tabs>
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-0 bg-blue-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-white" />
<div className="p-4 md:p-6 bg-slate-100 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<div className="flex gap-6">
{/* Left Sidebar - Summary */}
<div className="hidden lg:block w-72 flex-shrink-0 space-y-4">
{/* Logo Card */}
<Card className="border-0 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white overflow-hidden">
<CardContent className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div>
<h2 className="font-bold">Invoices</h2>
<p className="text-xs text-white/70">{visibleInvoices.length} total</p>
</div>
</div>
<div>
<p className="text-xs text-blue-600 uppercase tracking-wider font-semibold mb-0.5">Total Value</p>
<p className="text-2xl font-bold text-blue-700">${metrics.all.toLocaleString()}</p>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-white/70">Total Value</span>
<span className="font-bold text-lg">${metrics.all.toLocaleString()}</span>
</div>
<div className="h-2 bg-white/20 rounded-full overflow-hidden">
<div className="h-full bg-emerald-400 rounded-full" style={{ width: `${(metrics.paid / metrics.all) * 100 || 0}%` }} />
</div>
<p className="text-xs text-white/60">{((metrics.paid / metrics.all) * 100 || 0).toFixed(0)}% collected</p>
</div>
</div>
</CardContent>
</Card>
{userRole === "vendor" && (
<Button onClick={() => navigate(createPageUrl("InvoiceEditor"))} size="sm" className="w-full mt-4 bg-white text-[#0A39DF] hover:bg-white/90">
<Plus className="w-4 h-4 mr-1" /> New Invoice
</Button>
)}
</CardContent>
</Card>
<Card className="border-0 bg-amber-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center flex-shrink-0">
<DollarSign className="w-6 h-6 text-white" />
{/* Status Breakdown */}
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<h3 className="font-semibold text-slate-900 mb-3 text-sm">Status Breakdown</h3>
<div className="space-y-2">
{[
{ label: "Pending", status: "Pending Review", color: "bg-blue-500", value: getStatusCount("Pending Review"), amount: getTotalAmount("Pending Review") },
{ label: "Approved", status: "Approved", color: "bg-emerald-500", value: getStatusCount("Approved"), amount: getTotalAmount("Approved") },
{ label: "Disputed", status: "Disputed", color: "bg-red-500", value: getStatusCount("Disputed"), amount: getTotalAmount("Disputed") },
{ label: "Overdue", status: "Overdue", color: "bg-amber-500", value: getStatusCount("Overdue"), amount: getTotalAmount("Overdue") },
{ label: "Paid", status: "Paid", color: "bg-green-500", value: getStatusCount("Paid"), amount: getTotalAmount("Paid") },
{ label: "Reconciled", status: "Reconciled", color: "bg-purple-500", value: getStatusCount("Reconciled"), amount: getTotalAmount("Reconciled") },
].map(item => (
<button
key={item.status}
onClick={() => setActiveTab(item.label.toLowerCase())}
className={`w-full flex items-center gap-3 p-2 rounded-lg transition-all hover:bg-slate-50 ${activeTab === item.label.toLowerCase() ? 'bg-slate-100' : ''}`}
>
<div className={`w-2 h-2 rounded-full ${item.color}`} />
<span className="text-sm text-slate-700 flex-1 text-left">{item.label}</span>
<span className="text-xs text-slate-500 mr-1">${item.amount.toLocaleString()}</span>
<Badge variant="outline" className="text-xs">{item.value}</Badge>
</button>
))}
</div>
<div>
<p className="text-xs text-amber-600 uppercase tracking-wider font-semibold mb-0.5">Outstanding</p>
<p className="text-2xl font-bold text-amber-700">${metrics.outstanding.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<Card className="border-0 bg-red-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-6 h-6 text-white" />
{/* Sales & Revenue - Vendor Focused */}
<Card className="border-0 shadow-sm bg-emerald-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<h3 className="font-semibold text-emerald-900 text-sm">Sales & Revenue</h3>
</div>
<div>
<p className="text-xs text-red-600 uppercase tracking-wider font-semibold mb-0.5">Disputed</p>
<p className="text-2xl font-bold text-red-700">${metrics.disputed.toLocaleString()}</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-emerald-700">Total Sales</span>
<span className="font-semibold text-emerald-900">${metrics.all.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Collected</span>
<span className="font-semibold text-emerald-900">${metrics.paid.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Pending Revenue</span>
<span className="font-semibold text-emerald-900">${metrics.outstanding.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Collection Rate</span>
<span className="font-semibold text-emerald-900">{((metrics.paid / metrics.all) * 100 || 0).toFixed(0)}%</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-700">Avg. Invoice</span>
<span className="font-semibold text-emerald-900">${visibleInvoices.length > 0 ? Math.round(metrics.all / visibleInvoices.length).toLocaleString() : 0}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<Card className="border-0 bg-emerald-50 shadow-sm hover:shadow-md transition-all">
<CardContent className="p-5">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-emerald-500 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle className="w-6 h-6 text-white" />
{/* Quick Insights */}
<Card className="border-0 shadow-sm bg-amber-50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-amber-600" />
<h3 className="font-semibold text-amber-900 text-sm">Performance</h3>
</div>
<div>
<p className="text-xs text-emerald-600 uppercase tracking-wider font-semibold mb-0.5">Paid</p>
<p className="text-2xl font-bold text-emerald-700">${metrics.paid.toLocaleString()}</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-amber-700">Avg. Payment</span>
<span className="font-semibold text-amber-900">{insights.avgDays} days</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">On-Time Rate</span>
<span className="font-semibold text-amber-900">{insights.onTimeRate}%</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">This Month</span>
<span className="font-semibold text-amber-900">${insights.currentTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">Monthly Count</span>
<span className="font-semibold text-amber-900">{insights.currentMonthCount} invoices</span>
</div>
<div className="flex justify-between">
<span className="text-amber-700">MoM Change</span>
<span className={`font-semibold ${insights.isGrowth ? 'text-emerald-700' : 'text-red-700'}`}>
{insights.isGrowth ? '+' : ''}{insights.percentChange}%
</span>
</div>
{insights.topClient && (
<div className="flex justify-between">
<span className="text-amber-700">Top Client</span>
<span className="font-semibold text-amber-900 truncate max-w-[100px]" title={insights.topClient.name}>
{insights.topClient.name}
</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Smart Insights Banner */}
<div className="mb-6 bg-slate-100 rounded-2xl p-6 shadow-sm border border-slate-200">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-amber-500 rounded-xl flex items-center justify-center">
<Sparkles className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">Smart Insights</h3>
<p className="text-sm text-slate-500">AI-powered analysis of your invoice performance</p>
</div>
{/* Alerts */}
{(metrics.disputed > 0 || metrics.overdue > 0) && (
<Card className="border-0 shadow-sm border-l-4 border-l-red-500 bg-red-50">
<CardContent className="p-4">
<h3 className="font-semibold text-red-900 text-sm mb-2">Requires Attention</h3>
{metrics.disputed > 0 && (
<p className="text-xs text-red-700 mb-1"> {getStatusCount("Disputed")} disputed (${metrics.disputed.toLocaleString()})</p>
)}
{metrics.overdue > 0 && (
<p className="text-xs text-red-700"> {getStatusCount("Overdue")} overdue (${metrics.overdue.toLocaleString()})</p>
)}
</CardContent>
</Card>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">This Month</span>
<div className={`flex items-center gap-1 ${insights.isGrowth ? 'text-emerald-600' : 'text-red-600'}`}>
{insights.isGrowth ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span className="text-xs font-bold">{insights.percentChange}%</span>
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
{/* Top Bar */}
<div className="bg-white rounded-xl shadow-sm mb-4 p-3 flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="Search by invoice #, client, event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-10 bg-slate-50 border-0"
/>
</div>
<div className="lg:hidden">
{userRole === "vendor" && (
<Button onClick={() => navigate(createPageUrl("InvoiceEditor"))} size="sm" className="bg-[#0A39DF]">
<Plus className="w-4 h-4" />
</Button>
)}
</div>
<p className="text-2xl font-bold text-slate-900">${insights.currentTotal.toLocaleString()}</p>
<p className="text-xs text-slate-400 mt-1">{insights.currentMonthCount} invoices</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">Avg. Payment Time</span>
<Calendar className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.avgDays} days</p>
<p className="text-xs text-slate-400 mt-1">From issue to payment</p>
{/* Stats Row - Mobile */}
<div className="lg:hidden grid grid-cols-4 gap-2 mb-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-slate-900">${(metrics.all / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Total</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-amber-600">${(metrics.outstanding / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Pending</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-red-600">{getStatusCount("Disputed")}</p>
<p className="text-[10px] text-slate-500">Disputed</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-3 text-center">
<p className="text-lg font-bold text-emerald-600">${(metrics.paid / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-slate-500">Paid</p>
</CardContent>
</Card>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">On-Time Rate</span>
<CheckCircle className="w-4 h-4 text-slate-400" />
</div>
<p className="text-2xl font-bold text-slate-900">{insights.onTimeRate}%</p>
<p className="text-xs text-slate-400 mt-1">Paid before due date</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-500">
{userRole === "client" ? "Best Hub" : "Top Client"}
</span>
<ArrowUpRight className="w-4 h-4 text-slate-400" />
</div>
{userRole === "client" ? (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.bestHub?.hub || "—"}</p>
<p className="text-xs text-slate-400 mt-1">{insights.bestHub?.rate || 0}% on-time</p>
</>
) : (
<>
<p className="text-lg font-bold text-slate-900 truncate">{insights.topClient?.name || "—"}</p>
<p className="text-xs text-slate-400 mt-1">${insights.topClient?.amount.toLocaleString() || 0}</p>
</>
)}
</div>
</div>
</div>
{/* Search */}
<div className="bg-white rounded-lg p-4 mb-6 border border-slate-200">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by invoice number, client, event..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Invoices Table */}
<Card className="border-slate-200 shadow-lg">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Invoice #</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Hub</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Event</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Manager</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Date & Time</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Amount</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Status</TableHead>
<TableHead className="text-slate-600 font-semibold uppercase text-xs">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
<p className="font-medium">No invoices found</p>
</TableCell>
</TableRow>
) : (
filteredInvoices.map((invoice) => {
const invoiceDate = parseISO(invoice.issue_date);
const dayOfWeek = format(invoiceDate, 'EEEE');
const dateFormatted = format(invoiceDate, 'MM.dd.yy');
return (
<TableRow key={invoice.id} className="hover:bg-slate-50 transition-all border-b border-slate-100">
<TableCell className="font-bold text-slate-900">{invoice.invoice_number}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-purple-600" />
<span className="text-slate-900 font-medium">{invoice.hub || "—"}</span>
</div>
</TableCell>
<TableCell className="text-slate-900 font-medium">{invoice.event_name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-slate-400" />
<span className="text-slate-700">{invoice.manager_name || invoice.created_by || "—"}</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<div className="text-slate-900 font-medium">{dateFormatted}</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<Clock className="w-3 h-3" />
<span>{dayOfWeek}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0">
<DollarSign className="w-3 h-3 text-white" />
</div>
<span className="font-bold text-slate-900">${invoice.amount?.toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[invoice.status]} px-3 py-1 rounded-md text-xs`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
className="font-semibold hover:bg-blue-50 hover:text-[#0A39DF]"
>
<Eye className="w-4 h-4 mr-2" />
View
</Button>
{/* Invoice Table */}
<Card className="border-0 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50 border-b border-slate-200">
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide py-4">Invoice</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Client</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Event / Hub</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide">Date</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-right">Amount</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-center">Status</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-center">Due</TableHead>
<TableHead className="font-semibold text-slate-700 text-xs uppercase tracking-wide text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInvoices.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-16">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-200" />
<p className="font-medium text-slate-500">No invoices found</p>
<p className="text-sm text-slate-400 mt-1">Try adjusting your search or filters</p>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
filteredInvoices.map((invoice) => {
const invoiceDate = invoice.issue_date ? parseISO(invoice.issue_date) : new Date();
const dateFormatted = format(invoiceDate, 'MMM d, yyyy');
const dueDate = invoice.due_date ? format(parseISO(invoice.due_date), 'MMM d') : '—';
const isOverdue = invoice.due_date && isPast(parseISO(invoice.due_date)) && invoice.status !== "Paid" && invoice.status !== "Reconciled";
return (
<TableRow
key={invoice.id}
className={`hover:bg-blue-50/50 cursor-pointer transition-all border-b border-slate-100 ${isOverdue ? 'bg-red-50/30' : ''}`}
onClick={() => navigate(createPageUrl(`InvoiceDetail?id=${invoice.id}`))}
>
<TableCell className="py-4">
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
invoice.status === 'Paid' || invoice.status === 'Reconciled' ? 'bg-emerald-100' :
invoice.status === 'Disputed' ? 'bg-red-100' :
invoice.status === 'Overdue' ? 'bg-amber-100' : 'bg-blue-100'
}`}>
<FileText className={`w-4 h-4 ${
invoice.status === 'Paid' || invoice.status === 'Reconciled' ? 'text-emerald-600' :
invoice.status === 'Disputed' ? 'text-red-600' :
invoice.status === 'Overdue' ? 'text-amber-600' : 'text-blue-600'
}`} />
</div>
<div>
<p className="font-bold text-slate-900">{invoice.invoice_number}</p>
<p className="text-xs text-slate-500">{invoice.manager_name || '—'}</p>
</div>
</div>
</TableCell>
<TableCell>
<p className="font-medium text-slate-900 truncate max-w-[150px]">{invoice.business_name || '—'}</p>
</TableCell>
<TableCell>
<p className="font-medium text-slate-900 truncate max-w-[200px]">{invoice.event_name || 'Untitled'}</p>
<div className="flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-500">{invoice.hub || '—'}</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-700">{dateFormatted}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-bold text-slate-900 text-lg">${invoice.amount?.toLocaleString() || 0}</span>
</TableCell>
<TableCell className="text-center">
<Badge className={`${statusColors[invoice.status]} px-2.5 py-1`}>
{invoice.status}
</Badge>
</TableCell>
<TableCell className="text-center">
<span className={`text-sm font-medium ${isOverdue ? 'text-red-600' : 'text-slate-600'}`}>
{dueDate}
</span>
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="ghost" className="hover:bg-blue-100 hover:text-blue-700">
<Eye className="w-4 h-4" />
</Button>
<Button size="sm" variant="ghost" className="hover:bg-slate-100">
<Edit className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Table Footer */}
{filteredInvoices.length > 0 && (
<div className="p-4 border-t border-slate-100 bg-slate-50 flex items-center justify-between">
<p className="text-sm text-slate-500">
Showing <span className="font-medium text-slate-700">{filteredInvoices.length}</span> of <span className="font-medium text-slate-700">{visibleInvoices.length}</span> invoices
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Total: </span>
<span className="font-bold text-slate-900">${filteredInvoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}</span>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
</div>
<CreateInvoiceModal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
/>
</>
);
}

View File

@@ -4,14 +4,12 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { auth } from "@/firebase";
import { signOut } from "firebase/auth";
import {
Users, LayoutDashboard, UserPlus, Calendar, Briefcase, FileText,
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -36,119 +34,206 @@ import NotificationPanel from "@/components/notifications/NotificationPanel";
import { NotificationEngine } from "@/components/notifications/NotificationEngine";
import { Toaster } from "@/components/ui/toaster";
// Navigation items for each role
// Navigation items for each role - organized by categories
const roleNavigationMap = {
admin: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("Dashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Management", items: [
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Payroll", url: createPageUrl("Payroll"), icon: DollarSign },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Activity Log", url: createPageUrl("ActivityLog"), icon: Activity },
]},
{ category: "Communication", items: [
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
]},
{ category: "Settings", items: [
{ title: "User Management", url: createPageUrl("UserManagement"), icon: Users },
{ title: "Permissions", url: createPageUrl("Permissions"), icon: Shield },
{ title: "Settings", url: createPageUrl("Settings"), icon: SettingsIcon },
]},
],
procurement: [
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("ProcurementDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "Enterprises", url: createPageUrl("EnterpriseManagement"), icon: Building2 },
{ title: "Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
]},
{ category: "Compliance", items: [
{ title: "Compliance", url: createPageUrl("WorkforceCompliance"), icon: Shield },
{ title: "Rate Matrix", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
operator: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "My Sectors", url: createPageUrl("SectorManagement"), icon: MapPin },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
sector: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("OperatorDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("Events"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Network", items: [
{ title: "Partners", url: createPageUrl("PartnerManagement"), icon: Briefcase },
{ title: "Vendors", url: createPageUrl("VendorManagement"), icon: Package },
{ title: "Clients", url: createPageUrl("Business"), icon: Users },
]},
{ category: "Workforce", items: [
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
client: [
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("ClientDashboard"), icon: LayoutDashboard },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Orders", items: [
{ title: "My Orders", url: createPageUrl("ClientOrders"), icon: Clipboard },
{ title: "New Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Marketplace", items: [
{ title: "Vendor Marketplace", url: createPageUrl("VendorMarketplace"), icon: Store },
{ title: "Compare Rates", url: createPageUrl("VendorRateCard"), icon: DollarSign },
]},
{ category: "Team", items: [
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
{ category: "Analytics", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: FileText },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
]},
{ category: "Support", items: [
{ title: "Tutorials", url: createPageUrl("Tutorials"), icon: Sparkles },
{ title: "Support", url: createPageUrl("Support"), icon: HelpCircle },
]},
],
vendor: [
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Workforce", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Communications", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Leads", url: createPageUrl("Business"), icon: UserCheck },
{ title: "Business", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("VendorDashboard"), icon: LayoutDashboard },
{ title: "Performance", url: createPageUrl("VendorPerformance"), icon: TrendingUp },
]},
{ category: "Workforce", items: [
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Staff Directory", url: createPageUrl("StaffDirectory"), icon: Users },
{ title: "Compliance", url: createPageUrl("VendorCompliance"), icon: Shield },
{ title: "Employee Documents", url: createPageUrl("EmployeeDocuments"), icon: FolderOpen },
{ title: "Team", url: createPageUrl("Teams"), icon: UserCheck },
]},
{ category: "Operations", items: [
{ title: "Orders", url: createPageUrl("VendorOrders"), icon: FileText },
{ title: "Create Order", url: createPageUrl("CreateEvent"), icon: UserPlus },
{ title: "Schedule", url: createPageUrl("Schedule"), icon: Calendar },
{ title: "Staff Availability", url: createPageUrl("StaffAvailability"), icon: Users },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Business", items: [
{ title: "Clients", url: createPageUrl("Business"), icon: Briefcase },
{ title: "Service Rates", url: createPageUrl("VendorRates"), icon: DollarSign },
{ title: "Savings Engine", url: createPageUrl("SavingsEngine"), icon: TrendingUp },
]},
{ category: "Finance", items: [
{ title: "Invoices", url: createPageUrl("Invoices"), icon: Clipboard },
]},
{ category: "Analytics", items: [
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Audit Trail", url: createPageUrl("ActivityLog"), icon: Activity },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
],
workforce: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "Onboard Staff", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
{ category: "Overview", items: [
{ title: "Home", url: createPageUrl("WorkforceDashboard"), icon: LayoutDashboard },
{ title: "Profile", url: createPageUrl("WorkforceProfile"), icon: Users },
]},
{ category: "Work", items: [
{ title: "Shift Requests", url: createPageUrl("WorkerShiftProposals"), icon: Calendar },
{ title: "My Shifts", url: createPageUrl("WorkforceShifts"), icon: Calendar },
{ title: "Task Board", url: createPageUrl("TaskBoard"), icon: CheckSquare },
]},
{ category: "Team", items: [
{ title: "Teams", url: createPageUrl("Teams"), icon: UserCheck },
{ title: "Onboarding", url: createPageUrl("StaffOnboarding"), icon: GraduationCap },
{ title: "Messages", url: createPageUrl("Messages"), icon: MessageSquare },
]},
{ category: "Growth", items: [
{ title: "Certifications", url: createPageUrl("Certification"), icon: Award },
{ title: "Earnings", url: createPageUrl("WorkforceEarnings"), icon: DollarSign },
{ title: "Reports", url: createPageUrl("Reports"), icon: BarChart3 },
]},
],
};
@@ -218,31 +303,37 @@ const getLayerColor = (role) => {
};
function NavigationMenu({ location, userRole, closeSheet }) {
const navigationItems = roleNavigationMap[userRole] || roleNavigationMap.admin;
const navigationCategories = roleNavigationMap[userRole] || roleNavigationMap.admin;
return (
<nav className="space-y-1">
<div className="px-4 py-2 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Main Menu
</div>
{navigationItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0A39DF] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
<nav className="space-y-4">
{navigationCategories.map((categoryGroup) => (
<div key={categoryGroup.category}>
<div className="px-4 py-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{categoryGroup.category}
</div>
<div className="space-y-0.5">
{categoryGroup.items.map((item) => {
const isActive = location.pathname === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0A39DF] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-slate-100 hover:text-[#1C323E]'
}`}
>
<item.icon className="w-4 h-4" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
@@ -281,7 +372,7 @@ export default function Layout({ children }) {
const userInitial = userName.charAt(0).toUpperCase();
const handleLogout = () => {
signOut(auth);
base44.auth.logout();
};
const handleRefresh = () => {
@@ -484,10 +575,6 @@ export default function Layout({ children }) {
<User className="w-4 h-4 mr-2" />My Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600 focus:text-red-600">
<LogOut className="w-4 h-4 mr-2" />
<span>Logout</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -4,18 +4,30 @@ import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Download, FileText, TrendingUp, Users, DollarSign, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Download, FileText, TrendingUp, Users, DollarSign, Zap, Calendar, Library, Clock, Coffee, Sparkles } from "lucide-react";
import StaffingCostReport from "@/components/reports/StaffingCostReport";
import StaffPerformanceReport from "@/components/reports/StaffPerformanceReport";
import ClientTrendsReport from "@/components/reports/ClientTrendsReport";
import OperationalEfficiencyReport from "@/components/reports/OperationalEfficiencyReport";
import CustomReportBuilder from "@/components/reports/CustomReportBuilder";
import ReportTemplateLibrary from "@/components/reports/ReportTemplateLibrary";
import ScheduledReports from "@/components/reports/ScheduledReports";
import ReportExporter from "@/components/reports/ReportExporter";
import { useToast } from "@/components/ui/use-toast";
export default function Reports() {
const [activeTab, setActiveTab] = useState("costs");
const [activeTab, setActiveTab] = useState("templates");
const [showExporter, setShowExporter] = useState(false);
const [exportReportName, setExportReportName] = useState("");
const [exportData, setExportData] = useState(null);
const { toast } = useToast();
const { data: user, refetch: refetchUser } = useQuery({
queryKey: ['current-user-reports'],
queryFn: () => base44.auth.me(),
});
const { data: events = [] } = useQuery({
queryKey: ['events-reports'],
queryFn: () => base44.entities.Event.list(),
@@ -34,6 +46,35 @@ export default function Reports() {
initialData: [],
});
const { data: vendors = [] } = useQuery({
queryKey: ['vendors-reports'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || 'admin';
const scheduledReports = user?.scheduled_reports || [];
const handleSelectTemplate = (template) => {
setExportReportName(template.name);
setExportData({
template: template.id,
events,
staff,
invoices,
vendors,
generated: new Date().toISOString()
});
setShowExporter(true);
};
const handlePreviewTemplate = (template) => {
toast({
title: `Preview: ${template.name}`,
description: template.description
});
};
const handleExportAll = () => {
const data = {
events,
@@ -57,126 +98,177 @@ export default function Reports() {
return (
<div className="p-4 md:p-8 bg-slate-50 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Reports & Analytics</h1>
<p className="text-sm text-slate-500 mt-1">
Comprehensive insights into staffing, costs, and performance
</p>
<div className="max-w-[1600px] mx-auto">
{/* Hero Header - Compact */}
<div className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] rounded-2xl p-5 mb-6 text-white">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center backdrop-blur-sm">
<FileText className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold">On-Demand Reporting Suite</h1>
<p className="text-white/70 text-sm">Intelligence that drives decisions</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden md:flex items-center gap-3 mr-4">
<div className="text-center px-3">
<p className="text-2xl font-bold">{events.length}</p>
<p className="text-[10px] text-white/60 uppercase">Events</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center px-3">
<p className="text-2xl font-bold">{staff.length}</p>
<p className="text-[10px] text-white/60 uppercase">Staff</p>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="text-center px-3">
<p className="text-2xl font-bold">${(invoices.reduce((s, i) => s + (i.amount || 0), 0) / 1000).toFixed(0)}K</p>
<p className="text-[10px] text-white/60 uppercase">Revenue</p>
</div>
</div>
<Button onClick={handleExportAll} size="sm" className="bg-white text-[#0A39DF] hover:bg-white/90 font-semibold">
<Download className="w-4 h-4 mr-1" />
Export All
</Button>
</div>
</div>
<Button onClick={handleExportAll} className="bg-[#0A39DF]">
<Download className="w-4 h-4 mr-2" />
Export All Data
</div>
{/* Quick Actions - Horizontal Chips */}
<div className="flex items-center gap-2 mb-5 overflow-x-auto pb-1">
<span className="text-xs text-slate-500 font-medium whitespace-nowrap">Quick:</span>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700 h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Weekly Labor Summary'); setExportData({ events, staff, invoices }); setShowExporter(true); }}
>
<Download className="w-3 h-3 mr-1" />
Labor Summary
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Vendor Scorecard'); setExportData({ vendors, events }); setShowExporter(true); }}
>
<TrendingUp className="w-3 h-3 mr-1" />
Vendor Scorecard
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Compliance Report'); setExportData({ staff, events }); setShowExporter(true); }}
>
<Users className="w-3 h-3 mr-1" />
Compliance
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Invoice Aging'); setExportData({ invoices }); setShowExporter(true); }}
>
<FileText className="w-3 h-3 mr-1" />
Invoice Aging
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs whitespace-nowrap"
onClick={() => { setExportReportName('Payroll Summary'); setExportData({ staff, events, invoices }); setShowExporter(true); }}
>
<DollarSign className="w-3 h-3 mr-1" />
Payroll
</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-blue-200 bg-blue-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-blue-600 font-semibold uppercase">Total Events</p>
<p className="text-2xl font-bold text-blue-700">{events.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-green-600 font-semibold uppercase">Active Staff</p>
<p className="text-2xl font-bold text-green-700">{staff.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-purple-200 bg-purple-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<DollarSign className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-purple-600 font-semibold uppercase">Total Revenue</p>
<p className="text-2xl font-bold text-purple-700">
${invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<p className="text-xs text-amber-600 font-semibold uppercase">Automation</p>
<p className="text-2xl font-bold text-amber-700">85%</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Report Tabs */}
{/* Report Tabs - Clean Design */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-white border">
<TabsTrigger value="costs">
<DollarSign className="w-4 h-4 mr-2" />
Staffing Costs
</TabsTrigger>
<TabsTrigger value="performance">
<TrendingUp className="w-4 h-4 mr-2" />
Staff Performance
</TabsTrigger>
<TabsTrigger value="clients">
<Users className="w-4 h-4 mr-2" />
Client Trends
</TabsTrigger>
<TabsTrigger value="efficiency">
<Zap className="w-4 h-4 mr-2" />
Operational Efficiency
</TabsTrigger>
<TabsTrigger value="custom">
<FileText className="w-4 h-4 mr-2" />
Custom Reports
</TabsTrigger>
</TabsList>
<div className="bg-white rounded-xl border shadow-sm p-1 mb-6">
<TabsList className="bg-transparent p-0 h-auto flex flex-wrap gap-1">
<TabsTrigger value="templates" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Library className="w-4 h-4 mr-1.5" />
Templates
</TabsTrigger>
<TabsTrigger value="scheduled" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Clock className="w-4 h-4 mr-1.5" />
Scheduled
{scheduledReports.length > 0 && (
<Badge className="ml-1.5 bg-purple-100 text-purple-700 h-5 px-1.5 text-[10px]">{scheduledReports.length}</Badge>
)}
</TabsTrigger>
<div className="w-px h-6 bg-slate-200 mx-1 self-center" />
<TabsTrigger value="costs" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<DollarSign className="w-4 h-4 mr-1.5" />
Labor Spend
</TabsTrigger>
<TabsTrigger value="performance" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<TrendingUp className="w-4 h-4 mr-1.5" />
Performance
</TabsTrigger>
<TabsTrigger value="clients" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Users className="w-4 h-4 mr-1.5" />
Clients
</TabsTrigger>
<TabsTrigger value="efficiency" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Zap className="w-4 h-4 mr-1.5" />
Efficiency
</TabsTrigger>
<div className="w-px h-6 bg-slate-200 mx-1 self-center" />
<TabsTrigger value="custom" className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white rounded-lg px-4 py-2">
<Sparkles className="w-4 h-4 mr-1.5" />
Custom Builder
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="templates" className="mt-6">
<ReportTemplateLibrary
userRole={userRole}
onSelectTemplate={handleSelectTemplate}
onPreview={handlePreviewTemplate}
/>
</TabsContent>
<TabsContent value="scheduled" className="mt-6">
<ScheduledReports
userRole={userRole}
scheduledReports={scheduledReports}
onUpdate={refetchUser}
/>
</TabsContent>
<TabsContent value="costs" className="mt-6">
<StaffingCostReport events={events} invoices={invoices} />
<StaffingCostReport events={events} invoices={invoices} userRole={userRole} />
</TabsContent>
<TabsContent value="performance" className="mt-6">
<StaffPerformanceReport staff={staff} events={events} />
<StaffPerformanceReport staff={staff} events={events} userRole={userRole} />
</TabsContent>
<TabsContent value="clients" className="mt-6">
<ClientTrendsReport events={events} invoices={invoices} />
<ClientTrendsReport events={events} invoices={invoices} userRole={userRole} />
</TabsContent>
<TabsContent value="efficiency" className="mt-6">
<OperationalEfficiencyReport events={events} staff={staff} />
<OperationalEfficiencyReport events={events} staff={staff} userRole={userRole} />
</TabsContent>
<TabsContent value="custom" className="mt-6">
<CustomReportBuilder events={events} staff={staff} invoices={invoices} />
</TabsContent>
</Tabs>
{/* Export Modal */}
<ReportExporter
open={showExporter}
onClose={() => setShowExporter(false)}
reportName={exportReportName}
reportData={exportData}
/>
</div>
</div>
);

View File

@@ -0,0 +1,326 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
TrendingUp, TrendingDown, DollarSign, Users, Target, Zap,
ArrowRight, CheckCircle, AlertTriangle, BarChart3, PieChart,
Calendar, Clock, Award, Shield, Sparkles, ArrowUpRight,
Building2, Briefcase, Package, RefreshCw, Download, Filter, Wallet, Brain
} from "lucide-react";
import PageHeader from "@/components/common/PageHeader";
import SavingsOverviewCards from "@/components/savings/SavingsOverviewCards";
import ContractConversionMap from "@/components/savings/ContractConversionMap";
import PredictiveSavingsModel from "@/components/savings/PredictiveSavingsModel";
import DynamicSavingsDashboard from "@/components/savings/DynamicSavingsDashboard";
import LaborSpendAnalysis from "@/components/savings/LaborSpendAnalysis";
import VendorPerformanceMatrix from "@/components/savings/VendorPerformanceMatrix";
import BudgetUtilizationTracker from "@/components/budget/BudgetUtilizationTracker";
import SmartOperationStrategies from "@/components/vendor/SmartOperationStrategies";
export default function SavingsEngine() {
const [activeTab, setActiveTab] = useState("overview");
const [timeRange, setTimeRange] = useState("30days");
const { data: user } = useQuery({
queryKey: ['current-user-savings'],
queryFn: () => base44.auth.me(),
});
const { data: assignments = [] } = useQuery({
queryKey: ['assignments-savings'],
queryFn: () => base44.entities.Assignment.list(),
initialData: [],
});
const { data: vendors = [] } = useQuery({
queryKey: ['vendors-savings'],
queryFn: () => base44.entities.Vendor.list(),
initialData: [],
});
const { data: workforce = [] } = useQuery({
queryKey: ['workforce-savings'],
queryFn: () => base44.entities.Workforce.list(),
initialData: [],
});
const { data: orders = [] } = useQuery({
queryKey: ['orders-savings'],
queryFn: () => base44.entities.Order.list(),
initialData: [],
});
const { data: rates = [] } = useQuery({
queryKey: ['rates-savings'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
const userRole = user?.user_role || user?.role || "admin";
// Calculate comprehensive metrics
const metrics = useMemo(() => {
const totalSpend = assignments.reduce((sum, a) => sum + (a.total_bill || 0), 0);
const contractedSpend = assignments.filter(a => a.vendor_id).reduce((sum, a) => sum + (a.total_bill || 0), 0);
const nonContractedSpend = totalSpend - contractedSpend;
const contractedRatio = totalSpend > 0 ? (contractedSpend / totalSpend) * 100 : 0;
// Calculate average rates
const avgContractedRate = rates.filter(r => r.is_approved_rate).reduce((sum, r) => sum + (r.client_rate || 0), 0) / Math.max(rates.filter(r => r.is_approved_rate).length, 1);
const avgNonContractedRate = rates.filter(r => !r.is_approved_rate).reduce((sum, r) => sum + (r.client_rate || 0), 0) / Math.max(rates.filter(r => !r.is_approved_rate).length, 1);
// Potential savings from conversion
const potentialSavingsPercent = avgNonContractedRate > avgContractedRate ? ((avgNonContractedRate - avgContractedRate) / avgNonContractedRate) * 100 : 15;
const potentialSavings = nonContractedSpend * (potentialSavingsPercent / 100);
// Performance metrics
const avgReliability = workforce.reduce((sum, w) => sum + (w.reliability_index || 75), 0) / Math.max(workforce.length, 1);
const noShowRate = workforce.reduce((sum, w) => sum + (w.no_shows || 0), 0) / Math.max(workforce.reduce((sum, w) => sum + (w.total_assignments || 1), 0), 1) * 100;
const fillRate = assignments.filter(a => a.assignment_status === "Completed").length / Math.max(assignments.length, 1) * 100;
return {
totalSpend,
contractedSpend,
nonContractedSpend,
contractedRatio,
potentialSavings,
potentialSavingsPercent,
avgContractedRate: avgContractedRate || 45,
avgNonContractedRate: avgNonContractedRate || 55,
avgReliability,
noShowRate,
fillRate,
activeVendors: vendors.filter(v => v.is_active).length,
totalWorkforce: workforce.length,
completedOrders: orders.filter(o => o.order_status === "Completed").length,
};
}, [assignments, vendors, workforce, orders, rates]);
// Generate savings projections
const projections = useMemo(() => {
const dailySavings = metrics.potentialSavings / 30;
return {
sevenDays: dailySavings * 7,
thirtyDays: metrics.potentialSavings,
quarter: metrics.potentialSavings * 3,
year: metrics.potentialSavings * 12,
};
}, [metrics]);
const getRoleSpecificTitle = () => {
switch (userRole) {
case "procurement": return "Vendor Network Intelligence";
case "operator": return "Workforce Optimization Hub";
case "sector": return "Staffing Performance Center";
case "client": return "Smart Staffing Savings";
case "vendor": return "Your Competitive Edge";
default: return "Workforce Optimization Engine";
}
};
const getRoleSpecificSubtitle = () => {
switch (userRole) {
case "procurement": return "Consolidate vendors, optimize rates, and strengthen your preferred network";
case "operator": return "Maximize utilization, reduce costs, and streamline vendor relationships";
case "sector": return "Improve coverage, boost reliability, and optimize staffing costs";
case "client": return "Get better rates by leveraging the Preferred Vendor Network";
case "vendor": return "Showcase your performance metrics and win more business";
default: return "Consolidate spend, optimize rates, and maximize vendor performance";
}
};
// Get role-specific tabs
const getRoleTabs = () => {
switch (userRole) {
case "procurement":
return [
{ value: "overview", label: "Network Overview", icon: BarChart3 },
{ value: "budget", label: "Budget Control", icon: Wallet },
{ value: "strategies", label: "Operation Strategies", icon: Brain },
{ value: "vendors", label: "Vendor Scorecard", icon: Package },
{ value: "predictions", label: "Rate Analysis", icon: TrendingUp },
{ value: "conversion", label: "Tier Optimization", icon: Target },
];
case "operator":
return [
{ value: "overview", label: "Enterprise View", icon: BarChart3 },
{ value: "budget", label: "Budget Tracker", icon: Wallet },
{ value: "strategies", label: "Smart Strategies", icon: Brain },
{ value: "labor", label: "Labor Efficiency", icon: Users },
{ value: "predictions", label: "Cost Forecasts", icon: TrendingUp },
{ value: "vendors", label: "Vendor Mix", icon: Package },
];
case "sector":
return [
{ value: "overview", label: "Location Dashboard", icon: BarChart3 },
{ value: "budget", label: "Site Budget", icon: Wallet },
{ value: "strategies", label: "Optimization", icon: Brain },
{ value: "labor", label: "Staff Coverage", icon: Users },
{ value: "predictions", label: "Scheduling", icon: Calendar },
];
case "client":
return [
{ value: "overview", label: "My Events", icon: BarChart3 },
{ value: "budget", label: "My Budget", icon: Wallet },
{ value: "strategies", label: "Cost Strategies", icon: Brain },
{ value: "predictions", label: "Cost Savings", icon: TrendingUp },
{ value: "vendors", label: "Vendor Options", icon: Package },
];
case "vendor":
return [
{ value: "overview", label: "My Performance", icon: BarChart3 },
{ value: "budget", label: "Revenue Tracker", icon: Wallet },
{ value: "strategies", label: "Growth Strategies", icon: Brain },
{ value: "labor", label: "Workforce Stats", icon: Users },
{ value: "predictions", label: "Growth Opportunities", icon: TrendingUp },
];
default:
return [
{ value: "overview", label: "Overview", icon: BarChart3 },
{ value: "budget", label: "Budget Intelligence", icon: Wallet },
{ value: "strategies", label: "Smart Strategies", icon: Brain },
{ value: "conversion", label: "Conversion Map", icon: Target },
{ value: "predictions", label: "Predictions", icon: TrendingUp },
{ value: "labor", label: "Labor Analysis", icon: Users },
{ value: "vendors", label: "Vendor Matrix", icon: Package },
];
}
};
const roleTabs = getRoleTabs();
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 min-h-screen">
<div className="max-w-[1800px] mx-auto">
<PageHeader
title={getRoleSpecificTitle()}
subtitle={getRoleSpecificSubtitle()}
actions={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Download className="w-4 h-4 mr-2" />
Export Report
</Button>
<Button size="sm" className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => window.location.reload()}>
<Sparkles className="w-4 h-4 mr-2" />
Run Analysis
</Button>
</div>
}
/>
{/* Time Range Selector */}
<div className="flex items-center gap-2 mb-6">
<span className="text-sm text-slate-500 font-medium">Analyze:</span>
{[
{ value: "7days", label: "7 Days" },
{ value: "30days", label: "30 Days" },
{ value: "quarter", label: "Quarter" },
{ value: "year", label: "Year" },
].map((range) => (
<Button
key={range.value}
variant={timeRange === range.value ? "default" : "outline"}
size="sm"
onClick={() => setTimeRange(range.value)}
className={timeRange === range.value ? "bg-[#0A39DF]" : ""}
>
{range.label}
</Button>
))}
</div>
{/* Overview Cards */}
<SavingsOverviewCards metrics={metrics} projections={projections} timeRange={timeRange} userRole={userRole} />
{/* Main Tabs - Role Specific */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-8">
<TabsList className="bg-white border border-slate-200 p-1 h-auto flex-wrap">
{roleTabs.map((tab) => {
const Icon = tab.icon;
return (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-[#0A39DF] data-[state=active]:text-white"
>
<Icon className="w-4 h-4 mr-2" />
{tab.label}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="overview" className="mt-6">
<DynamicSavingsDashboard
metrics={metrics}
projections={projections}
timeRange={timeRange}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="conversion" className="mt-6">
<ContractConversionMap
assignments={assignments}
vendors={vendors}
workforce={workforce}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="predictions" className="mt-6">
<PredictiveSavingsModel
metrics={metrics}
projections={projections}
assignments={assignments}
rates={rates}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="labor" className="mt-6">
<LaborSpendAnalysis
assignments={assignments}
workforce={workforce}
orders={orders}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="vendors" className="mt-6">
<VendorPerformanceMatrix
vendors={vendors}
assignments={assignments}
rates={rates}
metrics={metrics}
userRole={userRole}
/>
</TabsContent>
<TabsContent value="budget" className="mt-6">
<BudgetUtilizationTracker
userRole={userRole}
events={orders}
invoices={[]}
/>
</TabsContent>
<TabsContent value="strategies" className="mt-6">
<SmartOperationStrategies
userRole={userRole}
onSelectStrategy={(strategy) => console.log('Selected strategy:', strategy)}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { krowSDK } from "@/api/krowSDK";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { UserPlus, Users, LayoutGrid, List as ListIcon, Phone, MapPin, Calendar, Star } from "lucide-react";
import FilterBar from "@/components/staff/FilterBar";
import StaffCard from "@/components/staff/StaffCard";
import EmployeeCard from "@/components/staff/EmployeeCard";
import PageHeader from "@/components/common/PageHeader";
@@ -18,59 +18,48 @@ export default function StaffDirectory() {
const [locationFilter, setLocationFilter] = useState("all");
const [viewMode, setViewMode] = useState("grid"); // "grid" or "list"
const { user: authUser } = useAuth(); // Firebase auth user
const { data: krowUser, isLoading: isLoadingUser } = useQuery({
queryKey: ['krow-user', authUser?.uid],
queryFn: () => krowSDK.entities.User.get({ id: authUser.uid }), // Changed from .filter() to .get()
enabled: !!authUser?.uid,
select: (response) => response?.data?.user, // Adjusted to get single user object
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: staff, isLoading: isLoadingStaff } = useQuery({
const { data: staff, isLoading } = useQuery({
queryKey: ['staff'],
queryFn: () => krowSDK.entities.Staff.list(),
queryFn: () => base44.entities.Staff.list('-created_date'),
initialData: [],
select: (response) => {
// The API returns { data: { staffs: [...] } }, so we need to access the nested array.
if (response && response.data && Array.isArray(response.data.staffs)) {
return response.data.staffs;
}
return []; // Return empty array if the structure is not as expected.
},
});
const { data: events } = useQuery({
queryKey: ['events-for-staff-filter'],
queryFn: () => krowSDK.entities.Event.list(),
initialData: { data: [] },
enabled: !!krowUser,
select: (data) => data.data || [],
queryFn: () => base44.entities.Event.list(),
initialData: [],
enabled: !!user
});
const visibleStaff = React.useMemo(() => {
if (!krowUser || !staff) return [];
const userRole = krowUser.user_role || krowUser.role;
const userRole = user?.user_role || user?.role;
if (['admin', 'procurement', 'operator', 'sector'].includes(userRole.toLowerCase())) {
if (['admin', 'procurement'].includes(userRole)) {
return staff;
}
if (['operator', 'sector'].includes(userRole)) {
return staff;
}
if (userRole === 'vendor') {
return staff.filter(s =>
s.vendor_id === krowUser.id ||
s.vendor_name === krowUser.company_name ||
//s.created_by === krowUser.email
e.created_by === krowUser.id
s.vendor_id === user?.id ||
s.vendor_name === user?.company_name ||
s.created_by === user?.email
);
}
if (userRole === 'client') {
const clientEvents = events.filter(e =>
e.client_email === krowUser.email ||
e.business_name === krowUser.company_name ||
//e.created_by === krowUser.email
e.created_by === krowUser.id
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
const assignedStaffIds = new Set();
@@ -92,7 +81,7 @@ export default function StaffDirectory() {
}
return staff;
}, [staff, krowUser, events]);
}, [staff, user, events]);
const uniqueDepartments = [...new Set(visibleStaff.map(s => s.department).filter(Boolean))];
const uniqueLocations = [...new Set(visibleStaff.map(s => s.hub_location).filter(Boolean))];
@@ -109,11 +98,10 @@ export default function StaffDirectory() {
return matchesSearch && matchesDepartment && matchesLocation;
});
const canAddStaff = krowUser && ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes((krowUser.user_role || krowUser.role || '').toLowerCase());
const isLoading = isLoadingStaff || isLoadingUser;
const canAddStaff = ['admin', 'procurement', 'operator', 'sector', 'vendor'].includes(user?.user_role || user?.role);
const getPageTitle = () => {
const userRole = krowUser?.user_role || krowUser?.role;
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return "My Staff Directory";
if (userRole === 'client') return "Event Staff Directory";
if (userRole === 'workforce') return "Team Directory";
@@ -121,14 +109,14 @@ export default function StaffDirectory() {
};
const getPageSubtitle = () => {
const userRole = krowUser?.user_role || krowUser?.role;
const userRole = user?.user_role || user?.role;
if (userRole === 'vendor') return `${filteredStaff.length} of your staff members`;
if (userRole === 'client') return `${filteredStaff.length} staff assigned to your events`;
if (userRole === 'workforce') return `${filteredStaff.length} team members`;
return `${filteredStaff.length} ${filteredStaff.length === 1 ? 'member' : 'members'} found`;
};
const getCoverageColor = (percentage) => {
const getCoverageColor = (percentage) => {
if (!percentage) return "bg-red-100 text-red-700";
if (percentage >= 90) return "bg-green-100 text-green-700";
if (percentage >= 50) return "bg-yellow-100 text-yellow-700";

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { krowSDK } from "@/api/krowSDK";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
@@ -79,7 +79,7 @@ export default function Teams() {
const { data: user } = useQuery({
queryKey: ['current-user-teams'],
queryFn: () => krowSDK.auth.me(),
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role;
@@ -100,7 +100,7 @@ export default function Teams() {
*/
const { data: userTeam } = useQuery({
queryKey: ['user-team', user?.id, userRole],
queryFn: async () => {debugger;
queryFn: async () => {
if (!user?.id) {
console.warn("⚠️ No user ID found - cannot fetch team");
return null;
@@ -108,15 +108,13 @@ export default function Teams() {
// SECURITY: Fetch ALL teams and filter by owner_id
// This ensures only THIS user's team is returned
const result = await krowSDK.entities.Team.list('-created_date');
const allTeams = result?.data?.teams ?? [];//new, get array from object
const allTeams = await base44.entities.Team.list('-created_date');
// Find ONLY teams owned by this specific user
let team = allTeams.find(t => t.owner_id === user.id);
debugger;
// ISOLATION VERIFICATION
if (team && team.ownerId !== user.id) {//it had team.owner_id I changed it to team.ownerId
if (team && team.owner_id !== user.id) {
console.error("🚨 SECURITY VIOLATION: Team owner mismatch!");
return null;
}
@@ -124,29 +122,23 @@ export default function Teams() {
// Auto-create team if doesn't exist (first time user accesses Teams)
if (!team && user.id) {
console.log(`✅ Creating new isolated team for ${userRole} user: ${user.email}`);
const teamName = user.companyName || `${user.fullName}'s Team` || "My Team";
try {
team = await krowSDK.entities.Team.create({
data: {
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
//email: user.email,
//phone: user.phone || "",
//totalMembers: 0,
//active_members: 0,
//total_hubs: 0,
favoriteStaff: 0,//favoriteStaff_count: 0,
blockedStaff: 0,//blockedStaff_count: 0,
//departments: [], // Initialize with an empty array for departments
}
const teamName = user.company_name || `${user.full_name}'s Team` || "My Team";
team = await base44.entities.Team.create({
team_name: teamName,
owner_id: user.id, // CRITICAL: Links team to THIS user only
owner_name: user.full_name || user.email,
owner_role: userRole, // Tracks which layer this team belongs to
email: user.email,
phone: user.phone || "",
total_members: 0,
active_members: 0,
total_hubs: 0,
favorite_staff_count: 0,
blocked_staff_count: 0,
departments: [], // Initialize with an empty array for departments
});
});
} catch (err) {
console.log('🔥 Error in user-team queryFn:', err);
throw err; // deja que React Query lo maneje como error
}
console.log(`✅ Team created successfully for ${userRole}: ${team.id}`);
}
@@ -185,7 +177,7 @@ export default function Teams() {
}
// Fetch all members and filter by team_id
const allMembers = await krowSDK.entities.TeamMember.list('-created_date');
const allMembers = await base44.entities.TeamMember.list('-created_date');
// SECURITY: Only return members that belong to THIS user's team
const filteredMembers = allMembers.filter(m => m.team_id === userTeam.id);
@@ -210,7 +202,7 @@ export default function Teams() {
queryKey: ['team-invites', userTeam?.id],
queryFn: async () => {
if (!userTeam?.id) return [];
const allInvites = await krowSDK.entities.TeamMemberInvite.list('-invited_date');
const allInvites = await base44.entities.TeamMemberInvite.list('-invited_date');
return allInvites.filter(inv => inv.team_id === userTeam.id && inv.invite_status === 'pending');
},
enabled: !!userTeam?.id,
@@ -219,7 +211,7 @@ export default function Teams() {
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-favorites'],
queryFn: () => krowSDK.entities.Staff.list(),
queryFn: () => base44.entities.Staff.list(),
enabled: !!userTeam?.id,
initialData: [],
});
@@ -228,7 +220,7 @@ export default function Teams() {
queryKey: ['team-hubs-main', userTeam?.id],
queryFn: async () => {
if (!userTeam?.id) return [];
const allHubs = await krowSDK.entities.TeamHub.list('-created_date');
const allHubs = await base44.entities.TeamHub.list('-created_date');
return allHubs.filter(h => h.team_id === userTeam.id);
},
enabled: !!userTeam?.id,
@@ -259,7 +251,7 @@ export default function Teams() {
const firstHub = teamHubs.length > 0 ? teamHubs[0].hub_name : "";
const firstDept = uniqueDepartments.length > 0 ? uniqueDepartments[0] : "Operations";
const invite = await krowSDK.entities.TeamMemberInvite.create({
const invite = await base44.entities.TeamMemberInvite.create({
team_id: userTeam.id,
team_name: userTeam.team_name || "Team",
invite_code: inviteCode,
@@ -303,7 +295,7 @@ export default function Teams() {
if (data.hub && !existingHub) {
// Create new hub with department
await krowSDK.entities.TeamHub.create({
await base44.entities.TeamHub.create({
team_id: userTeam.id,
hub_name: data.hub,
address: "",
@@ -317,7 +309,7 @@ export default function Teams() {
const departmentExists = hubDepartments.some(d => d.department_name === data.department);
if (!departmentExists) {
await krowSDK.entities.TeamHub.update(existingHub.id, {
await base44.entities.TeamHub.update(existingHub.id, {
departments: [...hubDepartments, { department_name: data.department, cost_center: "" }]
});
queryClient.invalidateQueries({ queryKey: ['team-hubs-main', userTeam?.id] });
@@ -326,7 +318,7 @@ export default function Teams() {
const inviteCode = `TEAM-${Math.floor(10000 + Math.random() * 90000)}`;
const invite = await krowSDK.entities.TeamMemberInvite.create({
const invite = await base44.entities.TeamMemberInvite.create({
team_id: userTeam.id,
team_name: userTeam.team_name || "Team",
invite_code: inviteCode,
@@ -343,7 +335,7 @@ export default function Teams() {
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${inviteCode}`;
await krowSDK.integrations.Core.SendEmail({
await base44.integrations.Core.SendEmail({
from_name: userTeam.team_name || "KROW",
to: data.email,
subject: `🚀 Welcome to KROW! You've been invited to ${data.hub || userTeam.team_name}`,
@@ -447,7 +439,7 @@ export default function Teams() {
mutationFn: async (invite) => {
const registerUrl = `${window.location.origin}${createPageUrl('Onboarding')}?invite=${invite.invite_code}`;
await krowSDK.integrations.Core.SendEmail({
await base44.integrations.Core.SendEmail({
from_name: userTeam.team_name || "Team",
to: invite.email,
subject: `Reminder: You're invited to join ${userTeam.team_name || 'our team'}!`,
@@ -509,7 +501,7 @@ export default function Teams() {
});
const updateMemberMutation = useMutation({
mutationFn: ({ id, data }) => krowSDK.entities.TeamMember.update(id, data),
mutationFn: ({ id, data }) => base44.entities.TeamMember.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
setShowEditMemberDialog(false);
@@ -522,7 +514,7 @@ export default function Teams() {
});
const deactivateMemberMutation = useMutation({
mutationFn: ({ id }) => krowSDK.entities.TeamMember.update(id, { is_active: false }),
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: false }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
toast({
@@ -533,7 +525,7 @@ export default function Teams() {
});
const activateMemberMutation = useMutation({
mutationFn: ({ id }) => krowSDK.entities.TeamMember.update(id, { is_active: true }),
mutationFn: ({ id }) => base44.entities.TeamMember.update(id, { is_active: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['team-members', userTeam?.id] });
toast({
@@ -615,7 +607,7 @@ export default function Teams() {
}
// Update the team with new departments list
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: updatedDepartments
});
@@ -646,7 +638,7 @@ export default function Teams() {
const currentDepartments = userTeam.departments || [];
const updatedDepartments = currentDepartments.filter(dept => dept !== deptToDelete);
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: updatedDepartments
});
@@ -666,7 +658,7 @@ export default function Teams() {
};
const updateTeamMutation = useMutation({
mutationFn: ({ id, data }) => krowSDK.entities.Team.update(id, data),
mutationFn: ({ id, data }) => base44.entities.Team.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });
toast({
@@ -769,7 +761,7 @@ export default function Teams() {
}, [isGoogleMapsLoaded, showAddHubDialog]);
const createHubMutation = useMutation({
mutationFn: (hubData) => krowSDK.entities.TeamHub.create({
mutationFn: (hubData) => base44.entities.TeamHub.create({
...hubData,
team_id: userTeam.id,
is_active: true
@@ -885,12 +877,12 @@ export default function Teams() {
{member.department}
</Badge>
)}
{member.hub && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1">
<MapPin className="w-3 h-3" />
{member.hub}
</Badge>
)}
{userRole !== 'vendor' && member.hub && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1">
<MapPin className="w-3 h-3" />
{member.hub}
</Badge>
)}
</div>
</div>
@@ -1062,10 +1054,17 @@ export default function Teams() {
<Mail className="w-4 h-4" />
Invitations ({pendingInvites.length})
</TabsTrigger>
<TabsTrigger value="hubs" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<MapPin className="w-4 h-4" />
Hubs ({teamHubs.length})
</TabsTrigger>
{userRole === 'vendor' ? (
<TabsTrigger value="departments" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<Building2 className="w-4 h-4" />
Departments ({uniqueDepartments.length})
</TabsTrigger>
) : (
<TabsTrigger value="hubs" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<MapPin className="w-4 h-4" />
Hubs ({teamHubs.length})
</TabsTrigger>
)}
<TabsTrigger value="favorites" className="data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2">
<Star className="w-4 h-4" />
Favorites ({userTeam?.favorite_staff_count || 0})
@@ -1094,8 +1093,12 @@ export default function Teams() {
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
{userRole !== 'vendor' && (
<>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
</>
)}
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -1123,8 +1126,12 @@ export default function Teams() {
</Badge>
</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
{userRole !== 'vendor' && (
<>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
</>
)}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Button
@@ -1195,8 +1202,12 @@ export default function Teams() {
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Role</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Department</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
{userRole !== 'vendor' && (
<>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Hub Address</th>
</>
)}
<th className="text-left px-4 py-3 text-xs font-semibold text-slate-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -1227,8 +1238,12 @@ export default function Teams() {
</Badge>
</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.department || 'No Department'}</td>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
{userRole !== 'vendor' && (
<>
<td className="px-4 py-4 text-sm text-slate-700">{member.hub || 'No Hub'}</td>
<td className="px-4 py-4 text-sm text-slate-600">{memberHub?.address || 'No Address'}</td>
</>
)}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<Button
@@ -1327,6 +1342,79 @@ export default function Teams() {
)}
</TabsContent>
{/* Departments Tab (for Vendors) */}
<TabsContent value="departments">
<div className="space-y-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-[#1C323E] flex items-center gap-2">
<Building2 className="w-5 h-5 text-[#0A39DF]" />
Departments
</h3>
<Button
variant="outline"
onClick={handleAddDepartment}
>
<Plus className="w-4 h-4 mr-2" />
Add Department
</Button>
</div>
{uniqueDepartments.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{uniqueDepartments.map((dept) => (
<Card key={dept} className="border-2 border-slate-200 hover:border-[#0A39DF] transition-all">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-blue-600 rounded-lg flex items-center justify-center">
<Building2 className="w-5 h-5 text-white" />
</div>
<h4 className="font-bold text-slate-900">{dept}</h4>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-blue-50 hover:text-[#0A39DF]"
onClick={() => {
setEditingDepartment(dept);
setNewDepartment(dept);
setShowDepartmentDialog(true);
}}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-red-50 hover:text-red-600"
onClick={() => handleDeleteDepartment(dept)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
<div className="text-sm text-slate-600">
<span className="font-medium">{activeMembers.filter(m => m.department === dept).length}</span> members
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-16 bg-white rounded-xl border-2 border-dashed border-slate-200">
<Building2 className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<h3 className="text-xl font-semibold text-slate-700 mb-2">No Departments Yet</h3>
<p className="text-slate-500 mb-6">Create departments to organize your team</p>
<Button onClick={handleAddDepartment}>
<Plus className="w-4 h-4 mr-2" />
Add First Department
</Button>
</div>
)}
</div>
</TabsContent>
{/* Hubs Tab */}
<TabsContent value="hubs">
<div className="space-y-6">
@@ -2378,14 +2466,14 @@ export default function Teams() {
size="lg"
onClick={async () => {
const updatedDepartments = [...(selectedHubForDept.departments || []), newHubDepartment];
await krowSDK.entities.TeamHub.update(selectedHubForDept.id, {
await base44.entities.TeamHub.update(selectedHubForDept.id, {
departments: updatedDepartments
});
// Also add department to team's global department list
const teamDepartments = userTeam?.departments || [];
if (!teamDepartments.includes(newHubDepartment.department_name)) {
await krowSDK.entities.Team.update(userTeam.id, {
await base44.entities.Team.update(userTeam.id, {
departments: [...teamDepartments, newHubDepartment.department_name]
});
queryClient.invalidateQueries({ queryKey: ['user-team', user?.id, userRole] });

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import React, { useState, useMemo, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
@@ -26,6 +25,7 @@ import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import SmartAssignModal from "@/components/events/SmartAssignModal";
import ClientLoyaltyCard from "@/components/vendor/ClientLoyaltyCard";
const convertTo12Hour = (time24) => {
if (!time24 || time24 === "—") return time24;
@@ -119,6 +119,13 @@ const AVAILABLE_WIDGETS = [
category: 'Analytics',
categoryColor: 'bg-amber-100 text-amber-700',
},
{
id: 'client-loyalty',
title: 'Client Loyalty',
description: 'See which clients are loyal vs at-risk',
category: 'Insights',
categoryColor: 'bg-pink-100 text-pink-700',
},
{
id: 'top-performers',
title: 'Top Performers',
@@ -784,6 +791,10 @@ export default function VendorDashboard() {
</Card>
);
const renderClientLoyalty = () => (
<ClientLoyaltyCard vendorId={user?.id} vendorName={user?.company_name} />
);
const renderGoldVendors = () => (
<Card className="bg-white border-slate-200 shadow-sm">
<CardHeader className="pb-3 border-b border-slate-100">
@@ -839,6 +850,8 @@ export default function VendorDashboard() {
return renderTopClients();
case 'top-performers':
return renderTopPerformers();
case 'client-loyalty':
return renderClientLoyalty();
case 'gold-vendors':
return renderGoldVendors();
case 'quick-actions':
@@ -965,7 +978,7 @@ export default function VendorDashboard() {
</div>
)}
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('gold-vendors') || visibleWidgetIds.includes('quick-actions')) && (
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('client-loyalty') || visibleWidgetIds.includes('gold-vendors') || visibleWidgetIds.includes('quick-actions')) && (
<div className="grid grid-cols-3 gap-6">
{(visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('quick-actions')) && (
<div className="space-y-4">
@@ -992,7 +1005,7 @@ export default function VendorDashboard() {
</div>
)}
{(visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('gold-vendors')) && (
{(visibleWidgetIds.includes('top-clients') || visibleWidgetIds.includes('top-performers') || visibleWidgetIds.includes('client-loyalty') || visibleWidgetIds.includes('gold-vendors')) && (
<div className={`grid ${visibleWidgetIds.includes('revenue-carousel') || visibleWidgetIds.includes('quick-actions') ? 'col-span-2' : 'col-span-3'} grid-cols-3 gap-4`}>
{visibleWidgetIds.includes('top-clients') && (
<div className="relative group">
@@ -1014,6 +1027,16 @@ export default function VendorDashboard() {
{renderWidget('top-performers')}
</div>
)}
{visibleWidgetIds.includes('client-loyalty') && (
<div className="relative group">
{isCustomizing && (
<button onClick={() => handleRemoveWidget('client-loyalty')} className="absolute -top-3 -right-3 w-8 h-8 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center shadow-lg z-10">
<Minus className="w-4 h-4 text-white" />
</button>
)}
{renderWidget('client-loyalty')}
</div>
)}
{visibleWidgetIds.includes('gold-vendors') && (
<div className="relative group">
{isCustomizing && (

View File

@@ -29,8 +29,9 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Textarea } from "@/components/ui/textarea";
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake } from "lucide-react";
import { Search, MapPin, Users, Star, DollarSign, TrendingUp, MessageSquare, CheckCircle, Award, Filter, Grid, List, Phone, Mail, Building2, Zap, ArrowRight, ChevronDown, ChevronUp, UserCheck, Briefcase, Shield, Crown, X, Edit2, Clock, Target, Handshake, Settings } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import ClientVendorPreferences from "@/components/vendor/ClientVendorPreferences";
export default function VendorMarketplace() {
const navigate = useNavigate();
@@ -451,6 +452,13 @@ export default function VendorMarketplace() {
</Card>
)}
{/* Client Vendor Preferences */}
<ClientVendorPreferences
user={user}
vendors={vendorsWithMetrics}
onUpdate={() => queryClient.invalidateQueries({ queryKey: ['current-user-marketplace'] })}
/>
{/* Stats Cards */}
<div className="grid grid-cols-4 gap-4">
<Card className="border border-slate-200 bg-slate-50/50 hover:shadow-md transition-all">

View File

@@ -1,267 +1,489 @@
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
// Auth components
import ProtectedRoute from '@/components/auth/ProtectedRoute';
import PublicRoute from '@/components/auth/PublicRoute';
// Layout and pages
import Layout from "./Layout.jsx";
import Home from "./Home";
import Login from "./Login";
import Register from "./Register";
import Dashboard from "./Dashboard";
import StaffDirectory from "./StaffDirectory";
import AddStaff from "./AddStaff";
import EditStaff from "./EditStaff";
import Events from "./Events";
import CreateEvent from "./CreateEvent";
import EditEvent from "./EditEvent";
import EventDetail from "./EventDetail";
import Business from "./Business";
import Invoices from "./Invoices";
import Payroll from "./Payroll";
import Certification from "./Certification";
import Support from "./Support";
import Reports from "./Reports";
import Settings from "./Settings";
import ActivityLog from "./ActivityLog";
import AddBusiness from "./AddBusiness";
import EditBusiness from "./EditBusiness";
import ProcurementDashboard from "./ProcurementDashboard";
import OperatorDashboard from "./OperatorDashboard";
import VendorDashboard from "./VendorDashboard";
import WorkforceDashboard from "./WorkforceDashboard";
import Messages from "./Messages";
import ClientDashboard from "./ClientDashboard";
import Onboarding from "./Onboarding";
import ClientOrders from "./ClientOrders";
import ClientInvoices from "./ClientInvoices";
import VendorOrders from "./VendorOrders";
import VendorStaff from "./VendorStaff";
import VendorInvoices from "./VendorInvoices";
import VendorPerformance from "./VendorPerformance";
import WorkforceShifts from "./WorkforceShifts";
import WorkforceEarnings from "./WorkforceEarnings";
import WorkforceProfile from "./WorkforceProfile";
import UserManagement from "./UserManagement";
import Home from "./Home";
import VendorRateCard from "./VendorRateCard";
import Permissions from "./Permissions";
import WorkforceCompliance from "./WorkforceCompliance";
import Teams from "./Teams";
import CreateTeam from "./CreateTeam";
import TeamDetails from "./TeamDetails";
import VendorManagement from "./VendorManagement";
import PartnerManagement from "./PartnerManagement";
import EnterpriseManagement from "./EnterpriseManagement";
import VendorOnboarding from "./VendorOnboarding";
import SectorManagement from "./SectorManagement";
import AddEnterprise from "./AddEnterprise";
import AddSector from "./AddSector";
import AddPartner from "./AddPartner";
import EditVendor from "./EditVendor";
import SmartVendorOnboarding from "./SmartVendorOnboarding";
import InviteVendor from "./InviteVendor";
import VendorCompliance from "./VendorCompliance";
import EditPartner from "./EditPartner";
import EditSector from "./EditSector";
import EditEnterprise from "./EditEnterprise";
import VendorRates from "./VendorRates";
import VendorDocumentReview from "./VendorDocumentReview";
import VendorMarketplace from "./VendorMarketplace";
import RapidOrder from "./RapidOrder";
import SmartScheduler from "./SmartScheduler";
import StaffOnboarding from "./StaffOnboarding";
import NotificationSettings from "./NotificationSettings";
import TaskBoard from "./TaskBoard";
import InvoiceDetail from "./InvoiceDetail";
import InvoiceEditor from "./InvoiceEditor";
import apiDocsRaw from "./api-docs-raw";
import Tutorials from "./Tutorials";
import Schedule from "./Schedule";
import StaffAvailability from "./StaffAvailability";
import WorkerShiftProposals from "./WorkerShiftProposals";
const PAGES = {
Dashboard,
StaffDirectory,
AddStaff,
EditStaff,
Events,
CreateEvent,
EditEvent,
EventDetail,
Business,
Invoices,
Payroll,
Certification,
Support,
Reports,
Settings,
ActivityLog,
AddBusiness,
EditBusiness,
ProcurementDashboard,
OperatorDashboard,
VendorDashboard,
WorkforceDashboard,
Messages,
ClientDashboard,
Onboarding,
ClientOrders,
ClientInvoices,
VendorOrders,
VendorStaff,
VendorInvoices,
VendorPerformance,
WorkforceShifts,
WorkforceEarnings,
WorkforceProfile,
UserManagement,
Home,
VendorRateCard,
Permissions,
WorkforceCompliance,
Teams,
CreateTeam,
TeamDetails,
VendorManagement,
PartnerManagement,
EnterpriseManagement,
VendorOnboarding,
SectorManagement,
AddEnterprise,
AddSector,
AddPartner,
EditVendor,
SmartVendorOnboarding,
InviteVendor,
VendorCompliance,
EditPartner,
EditSector,
EditEnterprise,
VendorRates,
VendorDocumentReview,
VendorMarketplace,
RapidOrder,
SmartScheduler,
StaffOnboarding,
NotificationSettings,
TaskBoard,
InvoiceDetail,
InvoiceEditor,
Tutorials,
Schedule,
StaffAvailability,
WorkerShiftProposals,
};
import SavingsEngine from "./SavingsEngine";
import EmployeeDocuments from "./EmployeeDocuments";
import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-router-dom';
const PAGES = {
Dashboard: Dashboard,
StaffDirectory: StaffDirectory,
AddStaff: AddStaff,
EditStaff: EditStaff,
Events: Events,
CreateEvent: CreateEvent,
EditEvent: EditEvent,
EventDetail: EventDetail,
Business: Business,
Invoices: Invoices,
Payroll: Payroll,
Certification: Certification,
Support: Support,
Reports: Reports,
Settings: Settings,
ActivityLog: ActivityLog,
AddBusiness: AddBusiness,
EditBusiness: EditBusiness,
ProcurementDashboard: ProcurementDashboard,
OperatorDashboard: OperatorDashboard,
VendorDashboard: VendorDashboard,
WorkforceDashboard: WorkforceDashboard,
Messages: Messages,
ClientDashboard: ClientDashboard,
Onboarding: Onboarding,
ClientOrders: ClientOrders,
ClientInvoices: ClientInvoices,
VendorOrders: VendorOrders,
VendorStaff: VendorStaff,
VendorInvoices: VendorInvoices,
VendorPerformance: VendorPerformance,
WorkforceShifts: WorkforceShifts,
WorkforceEarnings: WorkforceEarnings,
WorkforceProfile: WorkforceProfile,
UserManagement: UserManagement,
Home: Home,
VendorRateCard: VendorRateCard,
Permissions: Permissions,
WorkforceCompliance: WorkforceCompliance,
Teams: Teams,
CreateTeam: CreateTeam,
TeamDetails: TeamDetails,
VendorManagement: VendorManagement,
PartnerManagement: PartnerManagement,
EnterpriseManagement: EnterpriseManagement,
VendorOnboarding: VendorOnboarding,
SectorManagement: SectorManagement,
AddEnterprise: AddEnterprise,
AddSector: AddSector,
AddPartner: AddPartner,
EditVendor: EditVendor,
SmartVendorOnboarding: SmartVendorOnboarding,
InviteVendor: InviteVendor,
VendorCompliance: VendorCompliance,
EditPartner: EditPartner,
EditSector: EditSector,
EditEnterprise: EditEnterprise,
VendorRates: VendorRates,
VendorDocumentReview: VendorDocumentReview,
VendorMarketplace: VendorMarketplace,
RapidOrder: RapidOrder,
SmartScheduler: SmartScheduler,
StaffOnboarding: StaffOnboarding,
NotificationSettings: NotificationSettings,
TaskBoard: TaskBoard,
InvoiceDetail: InvoiceDetail,
InvoiceEditor: InvoiceEditor,
apiDocsRaw: apiDocsRaw,
Tutorials: Tutorials,
Schedule: Schedule,
StaffAvailability: StaffAvailability,
WorkerShiftProposals: WorkerShiftProposals,
SavingsEngine: SavingsEngine,
EmployeeDocuments: EmployeeDocuments,
function _getCurrentPage(url) {
if (url.endsWith('/')) url = url.slice(0, -1);
let last = url.split('/').pop();
if (last.includes('?')) last = last.split('?')[0];
const pageName = Object.keys(PAGES).find(p => p.toLowerCase() === last.toLowerCase());
return pageName || 'Home'; // Default to Home
}
function _getCurrentPage(url) {
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
let urlLastPart = url.split('/').pop();
if (urlLastPart.includes('?')) {
urlLastPart = urlLastPart.split('?')[0];
}
function AppRoutes() {
const pageName = Object.keys(PAGES).find(page => page.toLowerCase() === urlLastPart.toLowerCase());
return pageName || Object.keys(PAGES)[0];
}
// Create a wrapper component that uses useLocation inside the Router context
function PagesContent() {
const location = useLocation();
const currentPage = _getCurrentPage(location.pathname);
return (
<Routes>
{/* Public Routes */}
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><Register /></PublicRoute>} />
<Layout currentPageName={currentPage}>
<Routes>
{/* Private Routes */}
<Route path="/*" element={
<ProtectedRoute>
<Layout currentPageName={currentPage}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/Dashboard" element={<Dashboard />} />
<Route path="/StaffDirectory" element={<StaffDirectory />} />
<Route path="/AddStaff" element={<AddStaff />} />
<Route path="/EditStaff" element={<EditStaff />} />
<Route path="/Events" element={<Events />} />
<Route path="/CreateEvent" element={<CreateEvent />} />
<Route path="/EditEvent" element={<EditEvent />} />
<Route path="/EventDetail" element={<EventDetail />} />
<Route path="/Business" element={<Business />} />
<Route path="/Invoices" element={<Invoices />} />
<Route path="/Payroll" element={<Payroll />} />
<Route path="/Certification" element={<Certification />} />
<Route path="/Support" element={<Support />} />
<Route path="/Reports" element={<Reports />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/ActivityLog" element={<ActivityLog />} />
<Route path="/AddBusiness" element={<AddBusiness />} />
<Route path="/EditBusiness" element={<EditBusiness />} />
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
<Route path="/VendorDashboard" element={<VendorDashboard />} />
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
<Route path="/Messages" element={<Messages />} />
<Route path="/ClientDashboard" element={<ClientDashboard />} />
<Route path="/Onboarding" element={<Onboarding />} />
<Route path="/ClientOrders" element={<ClientOrders />} />
<Route path="/ClientInvoices" element={<ClientInvoices />} />
<Route path="/VendorOrders" element={<VendorOrders />} />
<Route path="/VendorStaff" element={<VendorStaff />} />
<Route path="/VendorInvoices" element={<VendorInvoices />} />
<Route path="/VendorPerformance" element={<VendorPerformance />} />
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
<Route path="/UserManagement" element={<UserManagement />} />
<Route path="/Home" element={<Home />} />
<Route path="/VendorRateCard" element={<VendorRateCard />} />
<Route path="/Permissions" element={<Permissions />} />
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
<Route path="/Teams" element={<Teams />} />
<Route path="/CreateTeam" element={<CreateTeam />} />
<Route path="/TeamDetails" element={<TeamDetails />} />
<Route path="/VendorManagement" element={<VendorManagement />} />
<Route path="/PartnerManagement" element={<PartnerManagement />} />
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
<Route path="/SectorManagement" element={<SectorManagement />} />
<Route path="/AddEnterprise" element={<AddEnterprise />} />
<Route path="/AddSector" element={<AddSector />} />
<Route path="/AddPartner" element={<AddPartner />} />
<Route path="/EditVendor" element={<EditVendor />} />
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
<Route path="/InviteVendor" element={<InviteVendor />} />
<Route path="/VendorCompliance" element={<VendorCompliance />} />
<Route path="/EditPartner" element={<EditPartner />} />
<Route path="/EditSector" element={<EditSector />} />
<Route path="/EditEnterprise" element={<EditEnterprise />} />
<Route path="/VendorRates" element={<VendorRates />} />
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
</Routes>
</Layout>
</ProtectedRoute>
} />
</Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/Dashboard" element={<Dashboard />} />
<Route path="/StaffDirectory" element={<StaffDirectory />} />
<Route path="/AddStaff" element={<AddStaff />} />
<Route path="/EditStaff" element={<EditStaff />} />
<Route path="/Events" element={<Events />} />
<Route path="/CreateEvent" element={<CreateEvent />} />
<Route path="/EditEvent" element={<EditEvent />} />
<Route path="/EventDetail" element={<EventDetail />} />
<Route path="/Business" element={<Business />} />
<Route path="/Invoices" element={<Invoices />} />
<Route path="/Payroll" element={<Payroll />} />
<Route path="/Certification" element={<Certification />} />
<Route path="/Support" element={<Support />} />
<Route path="/Reports" element={<Reports />} />
<Route path="/Settings" element={<Settings />} />
<Route path="/ActivityLog" element={<ActivityLog />} />
<Route path="/AddBusiness" element={<AddBusiness />} />
<Route path="/EditBusiness" element={<EditBusiness />} />
<Route path="/ProcurementDashboard" element={<ProcurementDashboard />} />
<Route path="/OperatorDashboard" element={<OperatorDashboard />} />
<Route path="/VendorDashboard" element={<VendorDashboard />} />
<Route path="/WorkforceDashboard" element={<WorkforceDashboard />} />
<Route path="/Messages" element={<Messages />} />
<Route path="/ClientDashboard" element={<ClientDashboard />} />
<Route path="/Onboarding" element={<Onboarding />} />
<Route path="/ClientOrders" element={<ClientOrders />} />
<Route path="/ClientInvoices" element={<ClientInvoices />} />
<Route path="/VendorOrders" element={<VendorOrders />} />
<Route path="/VendorStaff" element={<VendorStaff />} />
<Route path="/VendorInvoices" element={<VendorInvoices />} />
<Route path="/VendorPerformance" element={<VendorPerformance />} />
<Route path="/WorkforceShifts" element={<WorkforceShifts />} />
<Route path="/WorkforceEarnings" element={<WorkforceEarnings />} />
<Route path="/WorkforceProfile" element={<WorkforceProfile />} />
<Route path="/UserManagement" element={<UserManagement />} />
<Route path="/Home" element={<Home />} />
<Route path="/VendorRateCard" element={<VendorRateCard />} />
<Route path="/Permissions" element={<Permissions />} />
<Route path="/WorkforceCompliance" element={<WorkforceCompliance />} />
<Route path="/Teams" element={<Teams />} />
<Route path="/CreateTeam" element={<CreateTeam />} />
<Route path="/TeamDetails" element={<TeamDetails />} />
<Route path="/VendorManagement" element={<VendorManagement />} />
<Route path="/PartnerManagement" element={<PartnerManagement />} />
<Route path="/EnterpriseManagement" element={<EnterpriseManagement />} />
<Route path="/VendorOnboarding" element={<VendorOnboarding />} />
<Route path="/SectorManagement" element={<SectorManagement />} />
<Route path="/AddEnterprise" element={<AddEnterprise />} />
<Route path="/AddSector" element={<AddSector />} />
<Route path="/AddPartner" element={<AddPartner />} />
<Route path="/EditVendor" element={<EditVendor />} />
<Route path="/SmartVendorOnboarding" element={<SmartVendorOnboarding />} />
<Route path="/InviteVendor" element={<InviteVendor />} />
<Route path="/VendorCompliance" element={<VendorCompliance />} />
<Route path="/EditPartner" element={<EditPartner />} />
<Route path="/EditSector" element={<EditSector />} />
<Route path="/EditEnterprise" element={<EditEnterprise />} />
<Route path="/VendorRates" element={<VendorRates />} />
<Route path="/VendorDocumentReview" element={<VendorDocumentReview />} />
<Route path="/VendorMarketplace" element={<VendorMarketplace />} />
<Route path="/RapidOrder" element={<RapidOrder />} />
<Route path="/SmartScheduler" element={<SmartScheduler />} />
<Route path="/StaffOnboarding" element={<StaffOnboarding />} />
<Route path="/NotificationSettings" element={<NotificationSettings />} />
<Route path="/TaskBoard" element={<TaskBoard />} />
<Route path="/InvoiceDetail" element={<InvoiceDetail />} />
<Route path="/InvoiceEditor" element={<InvoiceEditor />} />
<Route path="/api-docs-raw" element={<apiDocsRaw />} />
<Route path="/Tutorials" element={<Tutorials />} />
<Route path="/Schedule" element={<Schedule />} />
<Route path="/StaffAvailability" element={<StaffAvailability />} />
<Route path="/WorkerShiftProposals" element={<WorkerShiftProposals />} />
<Route path="/SavingsEngine" element={<SavingsEngine />} />
<Route path="/EmployeeDocuments" element={<EmployeeDocuments />} />
</Routes>
</Layout>
);
}
export default function Pages() {
return (
<Router>
<AppRoutes />
<PagesContent />
</Router>
);
}

View File

@@ -0,0 +1,31 @@
Extension Discovery Cache
=========================
This folder is used by `package:extension_discovery` to cache lists of
packages that contains extensions for other packages.
DO NOT USE THIS FOLDER
----------------------
* Do not read (or rely) the contents of this folder.
* Do write to this folder.
If you're interested in the lists of extensions stored in this folder use the
API offered by package `extension_discovery` to get this information.
If this package doesn't work for your use-case, then don't try to read the
contents of this folder. It may change, and will not remain stable.
Use package `extension_discovery`
---------------------------------
If you want to access information from this folder.
Feel free to delete this folder
-------------------------------
Files in this folder act as a cache, and the cache is discarded if the files
are older than the modification time of `.dart_tool/package_config.json`.
Hence, it should never be necessary to clear this cache manually, if you find a
need to do please file a bug.

View File

@@ -0,0 +1 @@
{"version":2,"entries":[{"package":"krow_mobile","rootUri":"../","packageUri":"lib/"},{"package":"krow_ui","rootUri":"../packages/krow_ui/","packageUri":"lib/"},{"package":"krow_staff","rootUri":"../apps/krow_staff/","packageUri":"lib/"}]}

662
mock_staff_app.md Normal file
View File

@@ -0,0 +1,662 @@
He analizado lib/screens/auth/profile_setup_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _steps
* Propósito: Define los pasos del proceso de configuración del perfil.
* Estructura de cada elemento `Map<String, dynamic>`:
1 {
2 'id': String, // Ejemplo: 'basic'
3 'title': String, // Ejemplo: 'Basic Info'
4 'icon': IconData // Ejemplo: LucideIcons.user
5 }
Ahora, pasaré al siguiente archivo: lib/screens/worker/benefits_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/benefits_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/benefits_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _benefitsData
* Propósito: Representa una lista de beneficios disponibles para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>`:
1 {
2 'id': String, // Ejemplo: 'sick', 'vacation'
3 'title': String, // Ejemplo: 'Sick Days', 'Vacation'
4 'current': int, // Horas/cantidad actuales
5 'total': int, // Horas/cantidad totales
6 'color': Color, // Color para la visualización
7 'description': String, // Descripción del beneficio
8 'history': List<Map<String, dynamic>>, // Lista anidada para el historial de beneficios
9 'requestLabel': String, // Texto para el botón de solicitud
10 'notice': String?, // Texto de aviso opcional
11 }
* Estructura Anidada (`history`): Cada elemento en la lista history tiene:
1 {
2 'date': String, // Ejemplo: '1 Jan, 2024'
3 'status': String, // Ejemplo: 'Pending', 'Submitted'
4 }
El siguiente archivo es: lib/screens/worker/worker_profile/finances/time_card_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../time_card_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/finances/time_card_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _timesheets
* Propósito: Representa una lista de hojas de tiempo registradas para los turnos trabajados por el empleado.
* Estructura de cada elemento `Map<String, dynamic>`:
1 {
2 'id': String, // Identificador único de la hoja de tiempo
3 'shift_id': String, // ID del turno asociado
4 'date': String, // Cadena ISO 8601 para la fecha, e.g., "2023-12-21T..."
5 'actual_start': String, // Hora de inicio real, e.g., '09:00'
6 'actual_end': String, // Hora de finalización real, e.g., '17:00'
7 'total_hours': double, // Horas totales trabajadas en el turno
8 'hourly_rate': double, // Tarifa por hora del turno
9 'total_pay': double, // Pago total por el turno
10 'status': String, // Estado de la hoja de tiempo, e.g., 'pending', 'approved', 'paid', 'disputed'
11 'shift_title': String, // Título del turno, e.g., 'Line Cook'
12 'client_name': String, // Nombre del cliente
13 'location': String, // Ubicación del turno
14 }
El siguiente archivo es: lib/screens/worker/worker_profile/support/faqs_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../support/faqs_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/support/faqs_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _faqData
* Propósito: Representa una lista categorizada de preguntas frecuentes.
* Estructura de cada elemento `Map<String, dynamic>` (que representa una categoría de FAQ):
1 {
2 'category': String, // Ejemplo: 'Getting Started', 'Shifts & Work'
3 'questions': List<Map<String, dynamic>>, // Lista anidada de preguntas y respuestas
4 }
* Estructura Anidada (`questions`): Cada elemento en la lista questions tiene:
1 {
2 'q': String, // El texto de la pregunta
3 'a': String, // El texto de la respuesta
4 }
El siguiente archivo es: lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile l/.../emergency_contact_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _contacts
* Propósito: Representa una lista de contactos de emergencia para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un contacto de emergencia):
1 {
2 'name': String, // Nombre del contacto
3 'phone': String, // Número de teléfono del contacto
4 'relationship': String, // Relación con el trabajador, e.g., 'family', 'spouse', 'friend', 'other'
5 }
El siguiente archivo es: lib/screens/worker/worker_profile/support/messages_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../messages_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/support/messages_screen.dart.
Este archivo contiene dos estructuras principales de List<Map<String, dynamic>>:
1. Nombre de la variable: _conversations
* Propósito: Representa una lista de conversaciones de chat.
* Estructura de cada elemento `Map<String, dynamic>` (que representa una conversación individual):
1 {
2 'sender_id': String, // ID del remitente/otra parte en la conversación
3 'sender_name': String, // Nombre visible del remitente/otra parte
4 'lastMessage': String, // Contenido del último mensaje en la conversación
5 'lastTime': DateTime, // Marca de tiempo del último mensaje
6 'unread': int, // Número de mensajes no leídos en la conversación
7 'messages': List<Map<String, dynamic>>, // Lista anidada de mensajes individuales dentro de la conversación
8 }
* Estructura Anidada (`messages`): Cada elemento en la lista messages tiene:
1 {
2 'content': String, // El contenido del mensaje
3 'sender_id': String, // El ID del remitente de este mensaje específico
4 }
El siguiente archivo es: lib/screens/worker/worker_profile/onboarding/attire_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../attire_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/onboarding/attire_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _attireOptions
* Propósito: Representa una lista de diferentes artículos de vestimenta que un trabajador podría necesitar o poseer.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un artículo de vestimenta):
1 {
2 'id': String, // Identificador único para el artículo de vestimenta, e.g., 'non_slip_shoes'
3 'label': String, // Nombre visible del artículo de vestimenta, e.g., 'Non Slip Shoes'
4 'icon': IconData, // Icono asociado con el artículo de vestimenta, e.g., LucideIcons.footprints
5 'imageUrl': String, // URL de una imagen del artículo de vestimenta
6 }
El siguiente archivo es: lib/screens/worker/worker_profile/level_up/trainings_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../trainings_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/level_up/trainings_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _courses
* Propósito: Representa una lista de cursos de capacitación disponibles para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo curso):
1 {
2 'id': String, // Identificador único para el curso
3 'title': String, // Título del curso
4 'description': String, // Descripción del curso
5 'duration_minutes': int, // Duración del curso en minutos
6 'xp_reward': int, // Puntos de experiencia otorgados al finalizar
7 'thumbnail_url': String?,// URL opcional para una miniatura del curso
8 'progress_percent': int, // Porcentaje de finalización del curso
9 'completed': bool, // Indica si el curso está completado
10 }
El siguiente archivo es: lib/screens/worker/worker_profile/compliance/documents_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../documents_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/compliance/documents_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _requiredDocs
* Propósito: Representa una lista de documentos requeridos para el cumplimiento del trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo documento):
1 {
2 'id': String, // Identificador único para el documento, e.g., 'id', 'ssn'
3 'name': String, // Nombre visible del documento, e.g., 'Government ID'
4 'description': String, // Breve descripción o ejemplos para el documento
5 'status': String, // Estado actual del documento, e.g., 'verified', 'pending', 'missing'
6 }
El siguiente archivo es: lib/screens/worker/worker_profile/finances/bank_account_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../bank_account_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/finances/bank_account_screen.screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _accounts
* Propósito: Representa una lista de cuentas bancarias vinculadas para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa una sola cuenta bancaria):
1 {
2 'id': int, // Identificador único para la cuenta
3 'bank': String, // Nombre del banco, e.g., 'Chase Bank'
4 'type': String, // Tipo de cuenta, e.g., 'Checking', 'Savings'
5 'last4': String, // Últimos 4 dígitos del número de cuenta
6 'isPrimary': bool, // Si esta es la cuenta principal
7 }
El siguiente archivo es: lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../tax_forms_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _forms
* Propósito: Representa una lista de formularios de impuestos requeridos al trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo formulario de impuestos):
1 {
2 'id': String, // Identificador único para el formulario, e.g., 'i9', 'w4'
3 'title': String, // Título oficial del formulario, e.g., 'Form I-9'
4 'subtitle': String, // Subtítulo o nombre completo del formulario
5 'description': String, // Breve descripción del propósito del formulario
6 'status': String, // Estado del formulario, e.g., 'submitted', 'not_started', 'draft', 'approved'
7 'icon': String, // Icono (emoji) o identificador para el icono del formulario
8 }
El siguiente archivo es: lib/screens/worker/worker_profile/compliance/certificates_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../certificates_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/compliance/certificates_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _certificates
* Propósito: Representa una lista de certificados (por ejemplo, verificación de antecedentes, manipulador de alimentos) requeridos para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo certificado):
1 {
2 'id': String, // Identificador único para el certificado, e.g., 'background', 'food_handler'
3 'name': String, // Nombre visible del certificado
4 'icon': IconData, // Icono asociado con el certificado
5 'color': Color, // Color para la visualización
6 'description': String, // Breve descripción del certificado
7 'status': String, // Estado del certificado, e.g., 'complete', 'expiring', 'not_started', 'pending'
8 'expiry': String?, // Cadena ISO 8601 para la fecha de vencimiento (puede ser nula)
9 }
El siguiente archivo es: lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../leaderboard_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _profiles
* Propósito: Representa una lista de perfiles de trabajadores para la tabla de clasificación. Esto es esencialmente un mock de datos de WorkerProfile para fines de visualización.
* Estructura de cada elemento `Map<String, dynamic>` (que representa la entrada de un trabajador en la tabla de clasificación):
1 {
2 'id': String, // Identificador único para el trabajador
3 'name': String, // Nombre visible del trabajador, e.g., 'Sarah Jenkins'
4 'photo_url': String?, // URL de la foto de perfil del trabajador (puede ser nula)
5 'xp': int, // Puntos de experiencia del trabajador
6 'level': String, // Nivel actual del trabajador, e.g., 'Krower III'
7 'user_id': String, // ID de usuario (correo electrónico en el mock), utilizado para identificar al usuario actual
8 }
El siguiente archivo es: lib/screens/worker/payments_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/payments_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/payments_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _recentPayments
* Propósito: Representa una lista de registros de pagos recientes por turnos trabajados.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo registro de pago):
1 {
2 'date': String, // Fecha del pago, e.g., 'Sat, Dec 6'
3 'title': String, // Título del turno, e.g., 'Cook'
4 'location': String, // Ubicación del turno
5 'address': String, // Dirección completa de la ubicación del turno
6 'workedTime': String, // Rango de tiempo trabajado, e.g., '2:00 PM - 10:00 PM'
7 'amount': double, // Cantidad de pago por el turno
8 'status': String, // Estado del pago, e.g., 'paid'
9 'hours': int, // Horas trabajadas en el turno
10 'rate': double, // Tarifa por hora del turno
11 }
El siguiente archivo es: lib/screens/worker/availability_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../availability_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/availability_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _timeSlots
* Propósito: Define los diferentes rangos horarios (mañana, tarde, noche) para los cuales un trabajador puede establecer su disponibilidad.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo rango horario):
1 {
2 'id': String, // Identificador único para el rango horario, e.g., 'morning'
3 'label': String, // Nombre visible, e.g., 'Morning'
4 'time': String, // Rango de tiempo, e.g., '4:00 AM - 12:00 PM'
5 'icon': IconData, // Icono asociado con el rango horario, e.g., LucideIcons.sunrise
6 'bg': Color, // Color de fondo para la visualización
7 'iconColor': Color, // Color del icono
8 }
El siguiente archivo es: lib/screens/worker/earnings_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/earnings_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ He analizado lib/screens/worker/earnings_screen.dart.
Aquí está la estructura del List<Map<String, dynamic>> que contiene:
* Nombre de la variable: _recentPayments
* Propósito: Representa una lista de resúmenes de pagos recientes para el trabajador.
* Estructura de cada elemento `Map<String, dynamic>` (que representa un solo resumen de pago):
1 {
2 'date': String, // Fecha del pago, e.g., 'Dec 15'
3 'amount': double, // Monto total pagado por este período
4 'shifts': int, // Número de turnos cubiertos por este pago
5 'status': String, // Estado del pago, e.g., 'paid'
6 }
El siguiente archivo es: lib/screens/worker/clock_in_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/clock_in_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ ¡He completado la búsqueda! He encontrado y analizado todas las instancias de List<Map<String, dynamic>> en los archivos .dart dentro de tu proyecto.
Aquí tienes la lista consolidada, junto con su propósito y la estructura de los Map internos:
---
1. lib/screens/auth/profile_setup_screen.dart
* Variable: _steps
* Propósito: Define los pasos del proceso de configuración del perfil.
* Estructura de cada elemento:
1 {
2 'id': String, // Ejemplo: 'basic'
3 'title': String, // Ejemplo: 'Basic Info'
4 'icon': IconData // Ejemplo: LucideIcons.user
5 }
---
2. lib/screens/worker/benefits_screen.dart
* Variable: _benefitsData
* Propósito: Lista de beneficios disponibles para el trabajador.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'current': int,
5 'total': int,
6 'color': Color,
7 'description': String,
8 'history': List<Map<String, dynamic>>, // <- Lista anidada
9 'requestLabel': String,
10 'notice': String?,
11 }
* Estructura anidada `history`:
1 {
2 'date': String,
3 'status': String,
4 }
---
3. lib/screens/worker/worker_profile/finances/time_card_screen.dart
* Variable: _timesheets
* Propósito: Lista de hojas de tiempo registradas para turnos trabajados.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'shift_id': String,
4 'date': String, // Formato ISO 8601
5 'actual_start': String,
6 'actual_end': String,
7 'total_hours': double,
8 'hourly_rate': double,
9 'total_pay': double,
10 'status': String,
11 'shift_title': String,
12 'client_name': String,
13 'location': String,
14 }
---
4. lib/screens/worker/worker_profile/support/faqs_screen.dart
* Variable: _faqData
* Propósito: Lista categorizada de preguntas frecuentes.
* Estructura de cada elemento (categoría):
1 {
2 'category': String,
3 'questions': List<Map<String, dynamic>>, // <- Lista anidada
4 }
* Estructura anidada `questions`:
1 {
2 'q': String, // Pregunta
3 'a': String, // Respuesta
4 }
---
5. lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart
* Variable: _contacts
* Propósito: Lista de contactos de emergencia para el trabajador.
* Estructura de cada elemento:
1 {
2 'name': String,
3 'phone': String,
4 'relationship': String, // Ejemplo: 'family', 'spouse', 'friend'
5 }
---
6. lib/screens/worker/worker_profile/support/messages_screen.dart
* Variable: _conversations
* Propósito: Lista de conversaciones de chat.
* Estructura de cada elemento (conversación):
1 {
2 'sender_id': String,
3 'sender_name': String,
4 'lastMessage': String,
5 'lastTime': DateTime,
6 'unread': int,
7 'messages': List<Map<String, dynamic>>, // <- Lista anidada
8 }
* Estructura anidada `messages`:
1 {
2 'content': String,
3 'sender_id': String,
4 }
---
7. lib/screens/worker/worker_profile/onboarding/attire_screen.dart
* Variable: _attireOptions
* Propósito: Lista de artículos de vestimenta que un trabajador podría necesitar o poseer.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'label': String,
4 'icon': IconData,
5 'imageUrl': String,
6 }
---
8. lib/screens/worker/worker_profile/level_up/trainings_screen.dart
* Variable: _courses
* Propósito: Lista de cursos de capacitación disponibles.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'description': String,
5 'duration_minutes': int,
6 'xp_reward': int,
7 'thumbnail_url': String?,
8 'progress_percent': int,
9 'completed': bool,
10 }
---
9. lib/screens/worker/worker_profile/compliance/documents_screen.dart
* Variable: _requiredDocs
* Propósito: Lista de documentos requeridos para el cumplimiento.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'description': String,
5 'status': String, // Ejemplo: 'verified', 'pending', 'missing'
6 }
---
10. lib/screens/worker/worker_profile/finances/bank_account_screen.dart
* Variable: _accounts
* Propósito: Lista de cuentas bancarias vinculadas.
* Estructura de cada elemento:
1 {
2 'id': int,
3 'bank': String,
4 'type': String, // Ejemplo: 'Checking', 'Savings'
5 'last4': String,
6 'isPrimary': bool,
7 }
---
11. lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart
* Variable: _forms
* Propósito: Lista de formularios de impuestos requeridos.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'subtitle': String,
5 'description': String,
6 'status': String, // Ejemplo: 'submitted', 'not_started', 'draft'
7 'icon': String, // Icono (emoji)
8 }
---
12. lib/screens/worker/worker_profile/compliance/certificates_screen.dart
* Variable: _certificates
* Propósito: Lista de certificados del trabajador.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'icon': IconData,
5 'color': Color,
6 'description': String,
7 'status': String, // Ejemplo: 'complete', 'expiring', 'not_started'
8 'expiry': String?, // Formato ISO 8601, puede ser nulo
9 }
---
13. lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart
* Variable: _profiles
* Propósito: Lista de perfiles de trabajadores para la tabla de clasificación.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'photo_url': String?,
5 'xp': int,
6 'level': String, // Ejemplo: 'Krower III'
7 'user_id': String,
8 }
---
14. lib/screens/worker/payments_screen.dart
* Variable: _recentPayments
* Propósito: Lista de registros de pagos recientes (detallados).
* Estructura de cada elemento:
1 {
2 'date': String, // Ejemplo: 'Sat, Dec 6'
3 'title': String,
4 'location': String,
5 'address': String,
6 'workedTime': String,
7 'amount': double,
8 'status': String, // Ejemplo: 'paid'
9 'hours': int,
10 'rate': double,
11 }
---
15. lib/screens/worker/availability_screen.dart
* Variable: _timeSlots
* Propósito: Define los rangos horarios (mañana, tarde, noche) para configurar la disponibilidad.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'label': String,
4 'time': String,
5 'icon': IconData,
6 'bg': Color,
7 'iconColor': Color,
8 }
---
16. lib/screens/worker/earnings_screen.dart
* Variable: _recentPayments
* Propósito: Lista de resúmenes de pagos recientes (menos detallado que el de payments_screen.dart).
* Estructura de cada elemento:
1 {
2 'date': String, // Ejemplo: 'Dec 15'
3 'amount': double,
4 'shifts': int,
5 'status': String, // Ejemplo: 'paid'
6 }
---
Esta lista detallada te da una visión completa de cómo se están manejando los datos en diferentes partes de la aplicación. Como te mencioné antes, la mayoría de estos son mocks locales y
no clases formales.
Auto (Gemini 2.5) /model

859
mock_staff_app_v2.md Normal file
View File

@@ -0,0 +1,859 @@
lib/services/mock_service.dart
En este archivo, el uso de Map<String, dynamic> se encuentra como un parámetro en una función.
1. Parámetro de Función: Future<void> createWorkerProfile(Map<String, dynamic> data)
* Propósito: Esta función simula la creación de un perfil de trabajador en el backend. Acepta un Map llamado data que contiene toda la información del perfil del nuevo trabajador.
* Estructura del `Map` esperado: Aunque la función en sí misma no revela la estructura, el lugar donde se la llama (profile_setup_screen.dart) nos muestra exactamente qué datos se envían:
1 {
2 'full_name': String,
3 'bio': String,
4 'preferred_locations': List<String>,
5 'max_distance_miles': double,
6 'skills': List<String>,
7 'industries': List<String>,
8 }
---
A continuación, analizaré el siguiente archivo: lib/widgets/shift_card.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/widgets/shift_card.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Continuando el análisis:
---
lib/widgets/shift_card.dart
En este widget, el uso de Map<String, dynamic> se encuentra como el tipo de retorno de una función.
1. Tipo de Retorno de Función: Map<String, dynamic> _calculateDuration()
* Propósito: Esta función calcula la duración de un turno en horas y devuelve también una cadena de texto para el tiempo de descanso.
* Estructura del `Map` devuelto:
1 {
2 'hours': int, // La duración calculada del turno en horas.
3 'breakTime': String, // Una cadena de texto que representa el tiempo de descanso, ej: '1 hour'.
4 }
---
Siguiente archivo: lib/screens/auth/profile_setup_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../profile_setup_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ De acuerdo, aquí está el análisis del archivo.
---
lib/screens/auth/profile_setup_screen.dart
Este archivo contiene dos usos distintos de Map<String, dynamic>:
1. Variable: static const List<Map<String, dynamic>> _steps
* Propósito: Define los pasos en el asistente de creación de perfiles.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'icon': IconData
5 }
2. Argumento de Función: El Map anónimo pasado a mockService.createWorkerProfile({ ... })
* Propósito: Recopilar y enviar los datos del perfil del nuevo usuario al servicio (en este caso, un mock).
* Estructura:
1 {
2 'full_name': String,
3 'bio': String,
4 'preferred_locations': List<String>,
5 'max_distance_miles': double,
6 'skills': List<String>,
7 'industries': List<String>
8 }
---
Siguiente archivo: lib/screens/worker/worker_profile_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker_profile_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Claro, aquí está el análisis del archivo.
---
lib/screens/worker/worker_profile_screen.dart
Este archivo contiene dos variables Map<String, dynamic> para datos de simulación (mock).
1. Variable: final Map<String, dynamic> _user
* Propósito: Almacena los datos básicos de identificación del usuario para mostrarlos en la pantalla de perfil.
* Estructura:
1 {
2 'full_name': String,
3 'email': String,
4 }
2. Variable: final Map<String, dynamic> _profile
* Propósito: Almacena estadísticas y atributos detallados del trabajador para mostrarlos en la pantalla de perfil.
* Estructura:
1 {
2 'level': String,
3 'photo_url': String?,
4 'total_shifts': int,
5 'average_rating': double,
6 'on_time_rate': int,
7 'no_show_count': int,
8 'cancellation_count': int,
9 'reliability_score': int,
10 'phone': String,
11 'skills': List, // Mockeado como una lista vacía
12 }
---
Siguiente archivo: lib/screens/worker/benefits_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/benefits_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ De acuerdo, aquí está el análisis del siguiente archivo.
---
lib/screens/worker/benefits_screen.dart
Este archivo utiliza Map<String, dynamic> para gestionar los datos de los beneficios del trabajador.
1. Variable: final List<Map<String, dynamic>> _benefitsData
* Propósito: Contiene los datos de simulación (mock) para los diferentes beneficios del trabajador, como días de enfermedad y vacaciones.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'current': int,
5 'total': int,
6 'color': Color,
7 'description': String,
8 'history': List<Map<String, dynamic>>, // <- Lista anidada
9 'requestLabel': String,
10 'notice': String?, // Opcional
11 }
* Estructura anidada de `history`:
1 {
2 'date': String,
3 'status': String
4 }
2. Parámetro de Función: void _handleRequest(Map<String, dynamic> benefit)
* Propósito: Maneja la acción cuando un usuario solicita un beneficio, tomando uno de los objetos Map de _benefitsData como entrada.
* Estructura: La misma que la de los elementos en _benefitsData.
3. Parámetro de Widget: final Map<String, dynamic> benefit (en _BenefitCard)
* Propósito: El widget _BenefitCard utiliza un Map de _benefitsData para mostrar los detalles de un beneficio.
* Estructura: La misma que la de los elementos en _benefitsData.
---
Siguiente archivo: lib/screens/worker/worker_profile/compliance/documents_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../documents_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/compliance/documents_screen.dart
Este archivo, similar a otros, usa Map<String, dynamic> para la gestión de datos de UI.
1. Variable: final List<Map<String, dynamic>> _requiredDocs
* Propósito: Almacena la lista de documentos de cumplimiento requeridos para el trabajador.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'description': String,
5 'status': String // Ejemplo: 'verified', 'pending', 'missing'
6 }
2. Parámetro de Función: Widget _buildDocumentCard(Map<String, dynamic> doc)
* Propósito: Es una función de construcción que crea una tarjeta de UI para un solo documento, tomando como entrada un Map de la lista _requiredDocs.
* Estructura: La estructura del parámetro doc es la misma que la de los elementos en la lista _requiredDocs.
---
Siguiente archivo: lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile l/.../emergency_contact_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart
Este archivo gestiona los contactos de emergencia del trabajador.
1. Variable: final List<Map<String, dynamic>> _contacts
* Propósito: Almacena una lista de los contactos de emergencia del trabajador.
* Estructura de cada elemento:
1 {
2 'name': String,
3 'phone': String,
4 'relationship': String // Ejemplo: 'family', 'spouse', 'friend', 'other'
5 }
2. Parámetro de Función: Widget _buildContactForm(int index, Map<String, dynamic> contact)
* Propósito: Es una función de construcción que toma un Map de un solo contacto de la lista _contacts para crear un formulario para ese contacto.
* Estructura: La estructura del parámetro contact es la misma que la de los elementos en la lista _contacts.
3. Parámetro de Función: void _updateContact(int index, String field, dynamic value)
* Propósito: Esta función actualiza un campo dentro de un Map de contacto específico en la lista _contacts. El value es dynamic ya que puede ser un String de un TextField o de un
DropdownButton.
* Estructura: No tiene una estructura de mapa específica, pero opera sobre los mapas dentro de la lista _contacts.
---
Siguiente archivo: lib/screens/worker/worker_profile/compliance/certificates_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../certificates_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Okay, aquí está el análisis del archivo certificates_screen.dart.
---
lib/screens/worker/worker_profile/compliance/certificates_screen.dart
Este archivo maneja los certificados de cumplimiento del trabajador.
1. Variable: final List<Map<String, dynamic>> _certificates
* Propósito: Almacena una lista de los certificados de cumplimiento del trabajador (por ejemplo, verificación de antecedentes, manipulador de alimentos).
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'icon': IconData,
5 'color': Color,
6 'description': String,
7 'status': String, // Ejemplo: 'complete', 'expiring', 'not_started'
8 'expiry': String?, // Cadena de fecha ISO 8601 que puede ser nula
9 }
2. Parámetro de Función: Widget _buildCertificateCard(Map<String, dynamic> cert)
* Propósito: Es una función de construcción que crea una tarjeta de UI para un solo certificado, tomando como entrada un Map de la lista _certificates.
* Estructura: La estructura del parámetro cert es la misma que la de los elementos en _certificates.
3. Parámetro de Función: void _showUploadModal(BuildContext context, Map<String, dynamic>? cert)
* Propósito: Esta función muestra una hoja modal para subir un certificado. Acepta un Map opcional cert. Si se proporciona cert, el modal es para renovar o ver ese certificado específico.
Si es null, es para subir un nuevo certificado sin categoría.
* Estructura: La estructura del parámetro cert es la misma que la de los elementos en _certificates.
---
Siguiente archivo: lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../tax_forms_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart
Este archivo gestiona la lista de formularios de impuestos del trabajador.
1. Variable: final List<Map<String, dynamic>> _forms
* Propósito: Almacena una lista de los formularios de impuestos requeridos.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'subtitle': String,
5 'description': String,
6 'status': String, // Ejemplo: 'submitted', 'not_started'
7 'icon': String, // Un emoji usado como icono
8 }
2. Parámetro de Función: Widget _buildFormCard(Map<String, dynamic> form)
* Propósito: Es una función de construcción que crea una tarjeta de UI para un solo formulario de impuestos, tomando como entrada un Map de la lista _forms.
* Estructura: La estructura del parámetro form es la misma que la de los elementos en la lista _forms.
---
Siguiente archivo: lib/screens/worker/worker_profile/onboarding/personal_info_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../personal_info_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/onboarding/personal_info_screen.dart
Este archivo contiene los datos de simulación (mock) para el formulario de información personal.
1. Variable: final Map<String, dynamic> _user
* Propósito: Almacena los datos básicos del usuario para mostrarlos en el formulario.
* Estructura:
1 {
2 'full_name': String,
3 'email': String,
4 'photo_url': String?, // Admite valores nulos
5 }
* Nota: Esta versión del mock _user es ligeramente diferente a la de worker_profile_screen.dart, ya que incluye el campo photo_url.
---
Siguiente archivo: lib/screens/worker/worker_profile/onboarding/attire_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../attire_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/onboarding/attire_screen.dart
Este archivo maneja el "armario" o la vestimenta que posee el trabajador.
1. Variable: final List<Map<String, dynamic>> _attireOptions
* Propósito: Proporciona una lista de opciones de vestimenta seleccionables para el trabajador.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'label': String,
4 'icon': IconData,
5 'imageUrl': String,
6 }
(Nota: Este archivo también contiene `Map<String, String>` y `Map<String, bool>` para la gestión del estado, pero no coinciden con el tipo `Map<String, dynamic>`).
---
Siguiente archivo: lib/screens/worker/worker_profile/compliance/taxforms/form_i9_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../form_i9_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/compliance/taxforms/form_i9_screen.dart
Este archivo contiene el formulario I-9 y utiliza mapas para gestionar los datos del formulario y los pasos del asistente.
1. Variable: final Map<String, dynamic> _formData
* Propósito: Almacena el estado de los campos del formulario I-9 mientras el usuario los completa.
* Estructura:
1 {
2 'lastName': String,
3 'firstName': String,
4 'middleInitial': String,
5 'otherLastNames': String,
6 'address': String,
7 'aptNumber': String,
8 'city': String,
9 'state': String?, // Admite valores nulos
10 'zipCode': String,
11 'dateOfBirth': String,
12 'ssn': String,
13 'email': String,
14 'phone': String,
15 'citizenshipStatus': String,
16 'uscisNumber': String,
17 'i94Number': String,
18 'foreignPassportNumber': String,
19 'countryOfIssuance': String,
20 'expirationDate': String,
21 }
2. Variable: final List<Map<String, String>> _steps
* Nota: Aunque este no es un Map<String, dynamic>, sigue un patrón similar.
* Propósito: Define los pasos del asistente del formulario I-9.
* Estructura de cada elemento:
1 {
2 'title': String,
3 'subtitle': String,
4 }
3. Parámetro de Función: void _updateField(String key, dynamic value)
* Propósito: Esta función actualiza un campo en el mapa _formData. El value es dynamic porque puede ser un String de un TextField o de un DropdownButton.
---
Siguiente archivo: lib/screens/worker/worker_profile/compliance/taxforms/form_w4_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../form_w4_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Claro, aquí tienes el análisis.
---
lib/screens/worker/worker_profile/compliance/taxforms/form_w4_screen.dart
Este archivo contiene el formulario W-4 y, de manera similar al I-9, utiliza mapas para gestionar los datos.
1. Variable: final Map<String, dynamic> _formData
* Propósito: Almacena el estado de los campos del formulario W-4 mientras el usuario los completa.
* Estructura:
1 {
2 'firstName': String,
3 'lastName': String,
4 'address': String,
5 'cityStateZip': String,
6 'ssn': String,
7 'filingStatus': String,
8 'multipleJobs': bool,
9 'qualifyingChildren': int,
10 'otherDependents': int,
11 'otherIncome': String, // Representa una cantidad en dólares
12 'deductions': String, // Representa una cantidad en dólares
13 'extraWithholding': String, // Representa una cantidad en dólares
14 }
2. Variable: final List<Map<String, String>> _steps
* Nota: Aunque este es un List<Map<String, String>>, sigue un patrón de uso de mapas para definir metadatos.
* Propósito: Define los pasos del asistente del formulario W-4.
* Estructura de cada elemento:
1 {
2 'title': String,
3 'subtitle': String,
4 }
3. Parámetro de Función: void _updateField(String key, dynamic value)
* Propósito: Esta función actualiza un campo en el mapa _formData. El value es dynamic porque puede ser un String, bool, o int dependiendo del campo del formulario que se esté
actualizando.
---
Siguiente archivo: lib/screens/worker/worker_profile/level_up/trainings_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../trainings_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ De acuerdo.
---
lib/screens/worker/worker_profile/level_up/trainings_screen.dart
Este archivo contiene la lista de cursos de capacitación para los trabajadores.
1. Variable: final List<Map<String, dynamic>> _courses
* Propósito: Almacena una lista de los cursos de capacitación disponibles.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'description': String,
5 'duration_minutes': int,
6 'xp_reward': int,
7 'thumbnail_url': String?, // Admite valores nulos
8 'progress_percent': int,
9 'completed': bool,
10 }
---
Siguiente archivo: lib/screens/worker/worker_profile/finances/time_card_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../time_card_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Okay, analicemos este archivo.
---
lib/screens/worker/worker_profile/finances/time_card_screen.dart
Este archivo se utiliza para mostrar el historial de horas trabajadas del empleado.
1. Variable: final List<Map<String, dynamic>> _timesheets
* Propósito: Almacena una lista de registros de hojas de tiempo (timesheets) para los turnos trabajados.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'shift_id': String,
4 'date': String, // Cadena en formato ISO 8601
5 'actual_start': String,
6 'actual_end': String,
7 'total_hours': double,
8 'hourly_rate': double,
9 'total_pay': double,
10 'status': String, // Ejemplo: 'pending', 'approved', 'paid'
11 'shift_title': String,
12 'client_name': String,
13 'location': String,
14 }
2. Parámetro de Función: Widget _buildShiftHistory(List<Map<String, dynamic>> timesheets)
* Propósito: Es una función de construcción que toma una lista de mapas de hojas de tiempo para renderizar la sección de historial.
* Estructura: La estructura del parámetro timesheets es una lista de mapas, donde cada mapa tiene la misma estructura que los elementos en _timesheets.
3. Parámetro de Función: Widget _buildTimesheetCard(Map<String, dynamic> timesheet)
* Propósito: Es una función de construcción que crea una tarjeta de UI para un único registro de hoja de tiempo de la lista _timesheets.
* Estructura: La estructura del parámetro timesheet es la misma que la de los elementos en _timesheets.
---
Siguiente archivo: lib/screens/worker/worker_profile/support/faqs_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../support/faqs_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/support/faqs_screen.dart
Este archivo contiene los datos para la pantalla de Preguntas Frecuentes (FAQs).
1. Variable: final List<Map<String, dynamic>> _faqData
* Propósito: Almacena los datos para las preguntas frecuentes, organizados por categoría.
* Estructura de cada elemento (categoría):
1 {
2 'category': String,
3 'questions': List<Map<String, dynamic>>, // <- Lista anidada
4 }
* Estructura anidada de `questions`:
1 {
2 'q': String, // La pregunta
3 'a': String, // La respuesta
4 }
(Nota: Este archivo también contiene un `Map<String, bool>` llamado `_openItems` para gestionar el estado de la UI, pero no coincide con el tipo `Map<String, dynamic>`).
---
Siguiente archivo: lib/screens/worker/worker_profile/level_up/krow_university_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../krow_university_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/level_up/krow_university_screen.dart
Este archivo es para la sección de "KROW University" y contiene varios usos de Map<String, dynamic>.
1. Variable: final Map<String, dynamic> _profile
* Propósito: Almacena un subconjunto de los datos del perfil del trabajador que son relevantes para la sección de universidad/capacitación.
* Estructura:
1 {
2 'level': String,
3 'xp': int,
4 'nextLevelXp': int,
5 'completed_courses': int,
6 }
* Nota: Este mapa _profile es inconsistente con el que se encuentra en worker_profile_screen.dart, lo que demuestra la necesidad de un modelo de datos unificado.
2. Variable: final List<Map<String, dynamic>> _categories
* Propósito: Define las categorías para los cursos de la universidad.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'label': String,
4 'icon': IconData,
5 }
3. Variable: final List<Map<String, dynamic>> _courses
* Propósito: Almacena una lista de los cursos universitarios disponibles.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'title': String,
4 'description': String,
5 'category': String,
6 'duration_minutes': int,
7 'xp_reward': int,
8 'level_required': String,
9 'is_certification': bool,
10 'progress_percent': int,
11 'completed': bool,
12 }
4. Parámetro de Función: Widget _buildCoursesGrid(List<Map<String, dynamic>> courses)
* Propósito: Es una función de construcción que toma una lista de mapas de cursos (filtrados de _courses) para renderizarlos en una cuadrícula.
* Estructura: La estructura del parámetro courses es una lista de mapas, donde cada mapa tiene la misma estructura que los elementos en _courses.
---
Siguiente archivo: lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../leaderboard_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos con el siguiente archivo.
---
lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart
Este archivo es para la pantalla de la tabla de clasificación (Leaderboard) y utiliza mapas extensivamente para manejar los datos de los perfiles.
1. Variable: final List<Map<String, dynamic>> _profiles
* Propósito: Almacena los datos de simulación (mock) para la tabla de clasificación, representando diferentes perfiles de trabajadores.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'name': String,
4 'photo_url': String?, // Admite valores nulos
5 'xp': int,
6 'level': String,
7 'user_id': String,
8 }
2. Variable: final sortedProfiles = List<Map<String, dynamic>>.from(_profiles)
* Propósito: Crea una copia mutable de la lista _profiles que puede ser ordenada por XP.
* Estructura: Los elementos tienen la misma estructura que en _profiles.
3. Parámetro de Función: Widget _buildPodium(List<Map<String, dynamic>> topThree)
* Propósito: Es una función de construcción que toma los tres perfiles principales para renderizar la sección del podio.
* Estructura: La lista topThree contiene mapas con la misma estructura que en _profiles.
4. Parámetro de Función: Widget _buildPodiumItem(Map<String, dynamic> profile, ...)
* Propósito: Es una función de construcción para crear la UI de un solo perfil en el podio.
* Estructura: El parámetro profile tiene la misma estructura que un elemento en _profiles.
5. Parámetro de Función: Widget _buildRestList(List<Map<String, dynamic>> rest, ...)
* Propósito: Es una función de construcción que toma el resto de los perfiles (que no están en el top tres) para renderizarlos como una lista estándar.
* Estructura: La lista rest contiene mapas con la misma estructura que en _profiles.
---
Siguiente archivo: lib/screens/worker/worker_profile/finances/bank_account_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../bank_account_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/worker_profile/finances/bank_account_screen.dart
Este archivo maneja las cuentas bancarias del trabajador.
1. Variable: final List<Map<String, dynamic>> _accounts
* Propósito: Almacena una lista de las cuentas bancarias vinculadas del trabajador.
* Estructura de cada elemento:
1 {
2 'id': int,
3 'bank': String,
4 'type': String, // Ejemplo: 'Checking', 'Savings'
5 'last4': String,
6 'isPrimary': bool,
7 }
2. Parámetro de Función: Widget _buildAccountCard(Map<String, dynamic> account)
* Propósito: Es una función de construcción que crea una tarjeta de UI para una sola cuenta bancaria, tomando como entrada un Map de la lista _accounts.
* Estructura: La estructura del parámetro account es la misma que la de los elementos en la lista _accounts.
---
Siguiente archivo: lib/screens/worker/payments_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/payments_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Sigamos.
---
lib/screens/worker/payments_screen.dart
Este archivo muestra el historial de pagos y ganancias del trabajador.
1. Variable: final List<Map<String, dynamic>> _recentPayments
* Propósito: Almacena los datos de simulación (mock) para una lista de registros de pagos recientes.
* Estructura de cada elemento:
1 {
2 'date': String,
3 'title': String,
4 'location': String,
5 'address': String,
6 'workedTime': String,
7 'amount': double,
8 'status': String, // Ejemplo: 'paid'
9 'hours': int,
10 'rate': int, // Nota: Esto es un int en el mock, pero probablemente debería ser double como en otros lugares.
11 }
* Nota: El widget PaymentHistoryItem que consume estos datos espera que rate sea un double, por lo que el int en los datos del mock es probablemente una pequeña inconsistencia.
---
Siguiente archivo: lib/screens/worker/availability_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../availability_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Okay, aquí está el análisis del archivo de disponibilidad.
---
lib/screens/worker/availability_screen.dart
Este archivo maneja la configuración de la disponibilidad del trabajador.
1. Variable: final List<Map<String, dynamic>> _timeSlots
* Propósito: Define las propiedades de los diferentes rangos horarios (Mañana, Tarde, Noche) que el usuario puede seleccionar.
* Estructura de cada elemento:
1 {
2 'id': String,
3 'label': String,
4 'time': String, // Ejemplo: '4:00 AM - 12:00 PM'
5 'icon': IconData,
6 'bg': Color,
7 'iconColor': Color,
8 }
(Nota: Este archivo también utiliza `Map<String, bool>` y `Map<String, Map<String, bool>>` para gestionar el estado de la disponibilidad, pero no coinciden con el tipo `Map<String,
dynamic>`).
---
Siguiente archivo: lib/screens/worker/clock_in_screen.dart.
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ✓ ReadFile lib/.../worker/clock_in_screen.dart │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
✦ Okay, aquí está el análisis del último archivo de la lista.
---
lib/screens/worker/clock_in_screen.dart
Este archivo gestiona la pantalla de fichaje (check-in/check-out) del trabajador.
1. Variable: final List<Map<String, dynamic>> _recentActivity
* Propósito: Almacena una lista de las actividades recientes de fichaje del trabajador.
* Estructura de cada elemento:
1 {
2 'date': DateTime,
3 'start': String, // Ejemplo: '09:00 AM'
4 'end': String, // Ejemplo: '05:00 PM'
5 'hours': String, // Ejemplo: '8h'
6 }
---
✦ He analizado lib/screens/worker/worker_profile/support/messages_screen.dart.
Este archivo contiene dos estructuras principales de List<Map<String, dynamic>>:
1. Nombre de la variable: _conversations
* Propósito: Representa una lista de conversaciones de chat.
* Estructura de cada elemento `Map<String, dynamic>` (que representa una conversación individual):
1 {
2 'sender_id': String, // ID del remitente/otra parte en la conversación
3 'sender_name': String, // Nombre visible del remitente/otra parte
4 'lastMessage': String, // Contenido del último mensaje en la conversación
5 'lastTime': DateTime, // Marca de tiempo del último mensaje
6 'unread': int, // Número de mensajes no leídos en la conversación
7 'messages': List<Map<String, dynamic>>, // Lista anidada de mensajes individuales dentro de la conversación
8 }
* Estructura Anidada (`messages`): Cada elemento en la lista messages tiene:
1 {
2 'content': String, // El contenido del mensaje
3 'sender_id': String, // El ID del remitente de este mensaje específico
4 }
El siguiente archivo es: lib/screens/worker/worker_profile/onboarding/attire_screen.dart.
---
He finalizado el análisis de todos los archivos que contenían Map<String, dynamic>. Has podido ver que este tipo de mapa se usa de forma muy extendida en todo el proyecto para simular
datos, pasar parámetros y definir el estado de los widgets.
La principal conclusión es la falta de modelos de datos formales (clases Dart), lo que lleva a la duplicación e inconsistencia de las estructuras de datos en diferentes pantallas.
Mi recomendación principal, como ingeniero senior, sería refactorizar este código para usar clases de modelo fuertemente tipadas. Esto mejoraría enormemente la mantenibilidad, la
legibilidad y la seguridad del código, reduciendo errores a largo plazo.
¿Hay alguna de estas estructuras que te interese formalizar en una clase Dart ahora? O, ¿tienes alguna otra pregunta?
Using: 1 GEMINI.md file

View File

@@ -0,0 +1,594 @@
# Análisis Exhaustivo de `Map<String, dynamic>` en el Proyecto
Este documento detalla todos los usos del tipo `Map<String, dynamic>` encontrados en los archivos `.dart` del proyecto, incluyendo variables, listas, parámetros y tipos de retorno de funciones.
---
### `lib/widgets/shift_card.dart`
#### 1. Tipo de Retorno de Función: `Map<String, dynamic> _calculateDuration()`
* **Propósito:** Calcula la duración de un turno en horas y devuelve también una cadena de texto para el tiempo de descanso.
* **Estructura del `Map` devuelto:**
```dart
{
'hours': int,
'breakTime': String,
}
```
---
### `lib/services/mock_service.dart`
#### 1. Parámetro de Función: `Future<void> createWorkerProfile(Map<String, dynamic> data)`
* **Propósito:** Simula la creación de un perfil de trabajador. Acepta un `Map` llamado `data` que contiene la información del perfil del nuevo trabajador.
* **Estructura Inferida (por su uso):**
```dart
{
'full_name': String,
'bio': String,
'preferred_locations': List<String>,
'max_distance_miles': double,
'skills': List<String>,
'industries': List<String>,
}
```
---
### `lib/screens/auth/profile_setup_screen.dart`
#### 1. Variable: `static const List<Map<String, dynamic>> _steps`
* **Propósito:** Define los pasos en el asistente de creación de perfiles.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'title': String,
'icon': IconData
}
```
#### 2. Argumento de Función: (Anónimo) en `mockService.createWorkerProfile`
* **Propósito:** Recopila y envía los datos del perfil del nuevo usuario al servicio mock.
* **Estructura:**
```dart
{
'full_name': String,
'bio': String,
'preferred_locations': List<String>,
'max_distance_miles': double,
'skills': List<String>,
'industries': List<String>
}
```
---
### `lib/screens/worker/benefits_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _benefitsData`
* **Propósito:** Contiene los datos de simulación (mock) para los diferentes beneficios del trabajador.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'title': String,
'current': int,
'total': int,
'color': Color,
'description': String,
'history': List<Map<String, dynamic>>, // <- Lista anidada
'requestLabel': String,
'notice': String?,
}
```
* **Estructura anidada de `history`:**
```dart
{
'date': String,
'status': String
}
```
#### 2. Parámetro de Función: `void _handleRequest(Map<String, dynamic> benefit)`
* **Propósito:** Maneja la acción cuando un usuario solicita un beneficio.
* **Estructura:** La misma que los elementos de `_benefitsData`.
#### 3. Parámetro de Widget: `final Map<String, dynamic> benefit`
* **Propósito:** El widget `_BenefitCard` recibe un `Map` para mostrar los detalles de un beneficio.
* **Estructura:** La misma que los elementos de `_benefitsData`.
---
### `lib/screens/worker/worker_profile_screen.dart`
#### 1. Variable: `final Map<String, dynamic> _user`
* **Propósito:** Almacena datos básicos de identificación del usuario.
* **Estructura:**
```dart
{
'full_name': String,
'email': String,
}
```
#### 2. Variable: `final Map<String, dynamic> _profile`
* **Propósito:** Almacena estadísticas y atributos detallados del perfil del trabajador.
* **Estructura:**
```dart
{
'level': String,
'photo_url': String?,
'total_shifts': int,
'average_rating': double,
'on_time_rate': int,
'no_show_count': int,
'cancellation_count': int,
'reliability_score': int,
'phone': String,
'skills': List,
}
```
---
### `lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _contacts`
* **Propósito:** Almacena una lista de los contactos de emergencia.
* **Estructura de cada elemento:**
```dart
{
'name': String,
'phone': String,
'relationship': String
}
```
#### 2. Parámetro de Función: `Widget _buildContactForm(int index, Map<String, dynamic> contact)`
* **Propósito:** Construye el widget del formulario para un contacto.
* **Estructura:** La misma que los elementos de `_contacts`.
---
### `lib/screens/worker/worker_profile/onboarding/personal_info_screen.dart`
#### 1. Variable: `final Map<String, dynamic> _user`
* **Propósito:** Almacena los datos básicos del usuario para el formulario.
* **Estructura:**
```dart
{
'full_name': String,
'email': String,
'photo_url': String?,
}
```
---
### `lib/screens/worker/worker_profile/finances/time_card_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _timesheets`
* **Propósito:** Almacena una lista de registros de hojas de tiempo (timesheets).
* **Estructura de cada elemento:**
```dart
{
'id': String,
'shift_id': String,
'date': String, // ISO 8601
'actual_start': String,
'actual_end': String,
'total_hours': double,
'hourly_rate': double,
'total_pay': double,
'status': String,
'shift_title': String,
'client_name': String,
'location': String,
}
```
#### 2. Parámetro de Función: `Widget _buildShiftHistory(List<Map<String, dynamic>> timesheets)`
* **Propósito:** Construye la sección de historial de turnos.
* **Estructura:** Una lista donde cada elemento tiene la estructura de `_timesheets`.
#### 3. Parámetro de Función: `Widget _buildTimesheetCard(Map<String, dynamic> timesheet)`
* **Propósito:** Construye la tarjeta para una sola hoja de tiempo.
* **Estructura:** La misma que los elementos de `_timesheets`.
---
### `lib/screens/worker/worker_profile/finances/bank_account_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _accounts`
* **Propósito:** Almacena una lista de las cuentas bancarias vinculadas.
* **Estructura de cada elemento:**
```dart
{
'id': int,
'bank': String,
'type': String,
'last4': String,
'isPrimary': bool,
}
```
#### 2. Parámetro de Función: `Widget _buildAccountCard(Map<String, dynamic> account)`
* **Propósito:** Construye la tarjeta para una sola cuenta bancaria.
* **Estructura:** La misma que los elementos de `_accounts`.
---
### `lib/screens/worker/worker_profile/level_up/trainings_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _courses`
* **Propósito:** Almacena una lista de cursos de capacitación.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'title': String,
'description': String,
'duration_minutes': int,
'xp_reward': int,
'thumbnail_url': String?,
'progress_percent': int,
'completed': bool,
}
```
---
### `lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _profiles`
* **Propósito:** Almacena los datos de los perfiles para la tabla de clasificación.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'name': String,
'photo_url': String?,
'xp': int,
'level': String,
'user_id': String,
}
```
#### 2. Variable: `final sortedProfiles = List<Map<String, dynamic>>.from(_profiles)`
* **Propósito:** Crea una copia mutable de la lista de perfiles para ordenarla.
* **Estructura:** Los elementos tienen la misma estructura que `_profiles`.
#### 3. Parámetro de Función: `Widget _buildPodium(List<Map<String, dynamic>> topThree)`
* **Propósito:** Construye la sección del podio con los 3 mejores perfiles.
* **Estructura:** Una lista donde cada mapa tiene la estructura de un elemento de `_profiles`.
#### 4. Parámetro de Función: `Map<String, dynamic> profile` (en `_buildPodiumItem`)
* **Propósito:** Construye el item para un perfil en el podio.
* **Estructura:** La misma que un elemento de `_profiles`.
#### 5. Parámetro de Función: `Widget _buildRestList(List<Map<String, dynamic>> rest, ...)`
* **Propósito:** Construye la lista para el resto de los perfiles.
* **Estructura:** Una lista donde cada mapa tiene la estructura de un elemento de `_profiles`.
---
### `lib/screens/worker/worker_profile/onboarding/attire_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _attireOptions`
* **Propósito:** Define las opciones de vestimenta que un trabajador puede seleccionar.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'label': String,
'icon': IconData,
'imageUrl': String,
}
```
---
### `lib/screens/worker/worker_profile/support/faqs_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _faqData`
* **Propósito:** Almacena los datos de las preguntas frecuentes, organizados por categoría.
* **Estructura de cada elemento (categoría):**
```dart
{
'category': String,
'questions': List<Map<String, dynamic>>, // <- Lista anidada
}
```
* **Estructura anidada de `questions`:**
```dart
{
'q': String, // Pregunta
'a': String, // Respuesta
}
```
---
### `lib/screens/worker/worker_profile/support/messages_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _conversations`
* **Propósito:** Contiene los datos de simulación para las conversaciones de chat.
* **Estructura de cada elemento (conversación):**
```dart
{
'sender_id': String,
'sender_name': String,
'lastMessage': String,
'lastTime': DateTime,
'unread': int,
'messages': List<Map<String, dynamic>>, // <- Lista anidada
}
```
* **Estructura anidada de `messages`:**
```dart
{
'content': String,
'sender_id': String,
}
```
#### 2. Variable: `Map<String, dynamic>? _selectedChat`
* **Propósito:** Almacena la conversación que el usuario ha seleccionado para ver.
* **Estructura:** La misma que un elemento de `_conversations`.
---
### `lib/screens/worker/payments_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _recentPayments`
* **Propósito:** Almacena registros detallados de pagos recientes.
* **Estructura de cada elemento:**
```dart
{
'date': String,
'title': String,
'location': String,
'address': String,
'workedTime': String,
'amount': double,
'status': String,
'hours': int,
'rate': int, // Debería ser double
}
```
---
### `lib/screens/worker/worker_profile/compliance/documents_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _requiredDocs`
* **Propósito:** Almacena la lista de documentos de cumplimiento requeridos.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'name': String,
'description': String,
'status': String,
}
```
#### 2. Parámetro de Función: `Widget _buildDocumentCard(Map<String, dynamic> doc)`
* **Propósito:** Construye la tarjeta de UI para un solo documento.
* **Estructura:** La misma que los elementos de `_requiredDocs`.
---
### `lib/screens/worker/worker_profile/compliance/tax_forms_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _forms`
* **Propósito:** Almacena la lista de formularios de impuestos.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'title': String,
'subtitle': String,
'description': String,
'status': String,
'icon': String, // Emoji
}
```
#### 2. Parámetro de Función: `Widget _buildFormCard(Map<String, dynamic> form)`
* **Propósito:** Construye la tarjeta de UI para un solo formulario.
* **Estructura:** La misma que los elementos de `_forms`.
---
### `lib/screens/worker/availability_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _timeSlots`
* **Propósito:** Define las propiedades de los diferentes rangos horarios para la disponibilidad.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'label': String,
'time': String,
'icon': IconData,
'bg': Color,
'iconColor': Color,
}
```
---
### `lib/screens/worker/worker_profile/compliance/certificates_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _certificates`
* **Propósito:** Almacena la lista de certificados de cumplimiento del trabajador.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'name': String,
'icon': IconData,
'color': Color,
'description': String,
'status': String,
'expiry': String?, // ISO 8601
}
```
#### 2. Parámetro de Función: `Widget _buildCertificateCard(Map<String, dynamic> cert)`
* **Propósito:** Construye la tarjeta de UI para un solo certificado.
* **Estructura:** La misma que los elementos de `_certificates`.
#### 3. Parámetro de Función: `void _showUploadModal(BuildContext context, Map<String, dynamic>? cert)`
* **Propósito:** Muestra un modal para subir un certificado.
* **Estructura:** La misma que los elementos de `_certificates`.
---
### `lib/screens/worker/worker_profile/compliance/taxforms/form_i9_screen.dart`
#### 1. Variable: `final Map<String, dynamic> _formData`
* **Propósito:** Almacena el estado de los campos del formulario I-9.
* **Estructura:**
```dart
{
'lastName': String,
'firstName': String,
'middleInitial': String,
'otherLastNames': String,
'address': String,
'aptNumber': String,
'city': String,
'state': String?,
'zipCode': String,
'dateOfBirth': String,
'ssn': String,
'email': String,
'phone': String,
'citizenshipStatus': String,
'uscisNumber': String,
'i94Number': String,
'foreignPassportNumber': String,
'countryOfIssuance': String,
'expirationDate': String,
}
```
---
### `lib/screens/worker/worker_profile/compliance/taxforms/form_w4_screen.dart`
#### 1. Variable: `final Map<String, dynamic> _formData`
* **Propósito:** Almacena el estado de los campos del formulario W-4.
* **Estructura:**
```dart
{
'firstName': String,
'lastName': String,
'address': String,
'cityStateZip': String,
'ssn': String,
'filingStatus': String,
'multipleJobs': bool,
'qualifyingChildren': int,
'otherDependents': int,
'otherIncome': String,
'deductions': String,
'extraWithholding': String,
}
```
---
### `lib/screens/worker/worker_profile/level_up/krow_university_screen.dart`
#### 1. Variable: `final Map<String, dynamic> _profile`
* **Propósito:** Almacena un subconjunto de datos del perfil relevantes para la universidad.
* **Estructura:**
```dart
{
'level': String,
'xp': int,
'badges': List<String>,
}
```
#### 2. Variable: `final List<Map<String, dynamic>> _levels`
* **Propósito:** Define los diferentes niveles de Krower y sus propiedades.
* **Estructura de cada elemento:**
```dart
{
'name': String,
'xpRequired': int,
'icon': IconData,
'colors': List<Color>,
}
```
#### 3. Variable: `final List<Map<String, dynamic>> _categories`
* **Propósito:** Define las categorías para los cursos.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'label': String,
'icon': IconData,
}
```
#### 4. Variable: `final List<Map<String, dynamic>> _courses`
* **Propósito:** Almacena la lista de cursos disponibles.
* **Estructura de cada elemento:**
```dart
{
'id': String,
'title': String,
'description': String,
'category': String,
'duration_minutes': int,
'xp_reward': int,
'level_required': String,
'is_certification': bool,
'progress_percent': int,
'completed': bool,
}
```
#### 5. Parámetro de Función: `Widget _buildCoursesList(List<Map<String, dynamic>> courses)`
* **Propósito:** Construye la lista de widgets de cursos.
* **Estructura:** Una lista donde cada mapa tiene la estructura de un elemento de `_courses`.
---
### `lib/screens/worker/earnings_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _recentPayments`
* **Propósito:** Almacena resúmenes de pagos recientes.
* **Estructura de cada elemento:**
```dart
{
'date': String,
'amount': double,
'shifts': int,
'status': String,
}
```
---
### `lib/screens/worker/clock_in_screen.dart`
#### 1. Variable: `final List<Map<String, dynamic>> _recentActivity`
* **Propósito:** Almacena una lista de actividades recientes de fichaje.
* **Estructura de cada elemento:**
```dart
{
'date': DateTime,
'start': String,
'end': String,
'hours': String,
}
```

View File

@@ -0,0 +1,286 @@
# Análisis de Mocks de Staff App vs. Schema de Data Connect
Este documento analiza las estructuras de datos "mock" encontradas en el código de la aplicación de Staff (Flutter) y las compara con los schemas GraphQL de Data Connect. El objetivo es identificar discrepancias y proponer cambios para alinear el backend con las necesidades del frontend.
---
## 1. Conversaciones y Mensajes (`messages_screen.dart`)
El mock `_conversations` representa la lista de chats de un usuario. Se compara con las entidades `Conversation` y `Message` del schema.
### Estructura del Mock
```dart
// Estructura de un elemento en la lista `_conversations`
{
'sender_id': String,
'sender_name': String,
'lastMessage': String,
'lastTime': DateTime,
'unread': int,
'messages': [
{
'content': String,
'sender_id': String,
}
]
}
```
### Entidades de Data Connect
**`dataconnect/schema/conversation.gql`**
```graphql
type Conversation @table(name: "conversations") {
id: UUID!
participants: String! // Se espera un array de IDs de usuario en formato JSON string
conversationType: ConversationType!
// ... otros campos
}
```
**`dataconnect/schema/message.gql`**
```graphql
type Message @table(name: "messages") {
id: UUID!
conversationId: UUID!
senderName: String!
content: String!
readBy: String // Se espera un array de IDs de usuario que han leído el mensaje
// ... otros campos
}
```
### Análisis y Discrepancias
1. **Modelo de Datos**: El mock está diseñado para una vista de "bandeja de entrada", donde cada item es una conversación y muestra un resumen (último mensaje, hora, contador de no leídos). El schema de Data Connect, en cambio, normaliza los datos en dos tablas: `conversations` y `messages`, lo cual es correcto desde el punto de vista de la base de datos.
2. **Campos de Resumen Faltantes**: La entidad `Conversation` no tiene campos para `lastMessage`, `lastTime` (o `updatedDate` que podría servir para ordenar, pero no es lo mismo que la fecha del último mensaje), ni un contador `unread`. Para construir la pantalla de la app, el cliente tendría que:
* Obtener todas las conversaciones.
* Para CADA conversación, obtener el último mensaje para mostrar `lastMessage` y `lastTime`.
* Para CADA conversación, contar los mensajes donde el `readBy` no incluya al usuario actual para saber el `unread`.
Esto es muy ineficiente (problema N+1).
3. **Identificación de Participantes**:
* El mock tiene `sender_id` y `sender_name`, lo que sugiere una conversación 1-a-1.
* La entidad `Conversation` tiene un campo `participants` (un JSON string de un array de IDs), que es más flexible y soporta chats grupales. El frontend deberá adaptar su lógica para manejar una lista de participantes en lugar de un único `sender`.
### Recomendaciones
Para optimizar la carga de datos y alinear el backend con las necesidades de la UI, recomiendo **desnormalizar** la entidad `Conversation` añadiendo campos de resumen.
**Archivo a modificar**: `dataconnect/schema/conversation.gql`
**Cambios sugeridos**:
```graphql
# EN: dataconnect/schema/conversation.gql
type Conversation @table(name: "conversations") {
id: UUID! @default(expr: "uuidV4()")
participants: String! # participants (jsonb -> String, required array of strings)
conversationType: ConversationType!
relatedTo: UUID! # related_to (generic FK as string)
status: ConversationStatus
createdDate: Timestamp @default(expr: "request.time")
updatedDate: Timestamp @default(expr: "request.time")
createdBy: String @default(expr: "auth.uid")
# --- CAMPOS AÑADIDOS PARA RESUMEN ---
lastMessage: String # Contenido del último mensaje
lastMessageSenderId: String # ID del remitente del último mensaje
lastMessageTimestamp: Timestamp # Fecha y hora del último mensaje
}
```
**Consideraciones Adicionales**:
* **Contador de no leídos (`unread`)**: Añadir un campo `unreadCount` a `Conversation` es complejo porque su valor depende de *quién* está mirando la conversación. Una mejor estrategia sería manejar esto en el lado del cliente o con una query más específica si el rendimiento lo permite. Por ahora, los campos de resumen del último mensaje son la mejora más crítica.
* **Lógica de Actualización**: Será necesario implementar una lógica (probablemente en una Cloud Function o en la mutación que crea mensajes) para que cada vez que se añada un `Message` nuevo, se actualicen los campos `lastMessage`, `lastMessageSenderId` y `lastMessageTimestamp` en la `Conversation` padre. Data Connect por sí solo no manejará esta lógica de desnormalización.
* **Alineación en la App**: La app de Flutter deberá cambiar su mock y su lógica para usar `participants` en lugar de `sender_id` y `sender_name`. El nombre del otro participante en un chat 1-a-1 se puede obtener buscando el ID en `participants` que no sea el del usuario actual.
---
## 2. Perfil de Staff y Leaderboard (`leaderboard_screen.dart`)
El mock `_profiles` se usa para mostrar la tabla de clasificación y parece ser un subconjunto de un perfil de trabajador (staff). Se compara con las entidades `Staff` y `User`.
### Estructura del Mock
```dart
// Estructura de un elemento en la lista `_profiles`
{
'id': String, // ID del perfil del trabajador (Staff)
'name': String,
'photo_url': String?,
'xp': int, // Puntos de experiencia
'level': String, // Nivel de gamificación, ej: 'Krower III'
'user_id': String, // ID de la entidad User (auth)
}
```
### Entidades de Data Connect
**`dataconnect/schema/staff.gql`**
```graphql
type Staff @table(name: "staffs") {
id: UUID!
employeeName: String!
# ... muchos otros campos relacionados con el trabajo
}
```
**`dataconnect/schema/user.gql`**
```graphql
type User @table(name: "users") {
id: String! // Uid de Firebase Auth
email: String!
fullName: String!
# ...
}
```
### Análisis y Discrepancias
1. **Relación `User` <-> `Staff` Faltante**: El mock contiene `id` (para el perfil `Staff`) y `user_id` (para la entidad `User`), lo cual es una arquitectura correcta. Sin embargo, la entidad `Staff` en el schema **no tiene un campo `userId`** para vincularlo con la tabla `User`. Esta es una relación fundamental que debe existir.
2. **Campos de Gamificación (`xp`, `level`)**: Estos campos son el núcleo de la funcionalidad del leaderboard, pero están completamente ausentes en la entidad `Staff`.
3. **Foto de Perfil (`photo_url`)**: Un campo para la URL de la foto de perfil es estándar en cualquier sistema con perfiles de usuario y falta en el schema de `Staff`.
4. **Convención de Nombres**: El mock usa `photo_url` y `user_id` (snake_case), mientras que la convención estándar de GraphQL y de los SDK generados por Data Connect es `camelCase`. La aplicación cliente debería usar `photoUrl` y `userId` para evitar inconsistencias.
### Recomendaciones
Para soportar la funcionalidad del leaderboard y establecer una arquitectura de datos robusta, se deben hacer los siguientes cambios.
**Archivo a modificar**: `dataconnect/schema/staff.gql`
**Cambios sugeridos**:
```graphql
# EN: dataconnect/schema/staff.gql
type Staff @table(name: "staffs") {
id: UUID! @default(expr: "uuidV4()")
userId: String! # --- AÑADIDO: Vínculo a la tabla User (FK a User.id)
employeeName: String! @col(name: "employee_name")
# ... (campos existentes)
# --- CAMPOS AÑADIDOS PARA PERFIL Y GAMIFICACIÓN ---
photoUrl: String # URL de la foto de perfil
xp: Int @default(expr: "0") # Puntos de experiencia, con valor inicial de 0
level: String # Nivel de gamificación (ej: "Beginner", "Krower I")
# ... (resto de campos existentes)
createdDate: Timestamp @default(expr: "request.time")
updatedDate: Timestamp @default(expr: "request.time")
createdBy: String @default(expr: "auth.uid")
}
```
**Consideraciones Adicionales**:
* **`userId` debe ser único**: Se debería añadir una constraint a nivel de base de datos para asegurar que no pueda haber dos perfiles de `Staff` con el mismo `userId`.
* **Alineación en la App**: Recomendar al equipo de Flutter que estandarice el uso de `camelCase` en las claves de sus `Map` para que coincidan con los nombres de campo de GraphQL (`photoUrl`, `userId`, `employeeName`).
* **`User.fullName` vs `Staff.employeeName`**: El sistema debe decidir cuál es la fuente de verdad para el nombre del usuario. Generalmente, `User.fullName` es el nombre legal/de registro, y `Staff.employeeName` podría ser un alias o nombre profesional. Para el leaderboard, `employeeName` parece más apropiado.
---
## 3. Hojas de Tiempo (Timesheets) y Pagos (`time_card_screen.dart`)
El mock `_timesheets` representa los registros de horas trabajadas que son la base para el cálculo de pagos. Esto es un concepto distinto al de un `Shift` (turno programado).
### Estructura del Mock
```dart
// Estructura de un elemento en la lista `_timesheets`
{
'id': String,
'shift_id': String,
'date': String,
'actual_start': String, // Hora de entrada real
'actual_end': String, // Hora de salida real
'total_hours': double,
'hourly_rate': double,
'total_pay': double,
'status': String, // ej: 'pending', 'approved', 'paid'
'shift_title': String, // Dato desnormalizado
'client_name': String, // Dato desnormalizado
'location': String, // Dato desnormalizado
}
```
### Entidad de Data Connect (`shift.gql`)
```graphql
// dataconnect/schema/shift.gql
type Shift @table(name: "shifts") {
id: UUID!
shiftName: String!
startDate: Timestamp! // Hora de inicio programada
endDate: Timestamp // Hora de fin programada
assignedStaff: String
# ...
}
```
### Análisis y Discrepancias
1. **Entidad Faltante: `Timesheet`**: La entidad `Shift` representa un evento *programado*. El mock `_timesheets`, sin embargo, describe el trabajo *realizado*. Es una práctica estándar separar estos dos conceptos. Se necesita una nueva entidad, que podríamos llamar `Timesheet` o `WorkLog`, que registre las horas reales y su estado de aprobación y pago.
2. **Campos del Mock**: El mock contiene todos los campos necesarios para la nueva entidad:
* Relación con el turno: `shift_id`.
* Tiempos reales: `actual_start`, `actual_end`.
* Cálculos de pago: `total_hours`, `hourly_rate`, `total_pay`.
* Ciclo de vida: `status`.
3. **Datos Desnormalizados**: El mock incluye `shift_title`, `client_name` y `location` para facilitar su visualización en la app. En el backend, estos datos no deben duplicarse en la tabla `Timesheet`. La nueva entidad solo debe contener el `shiftId`, y los datos relacionados se obtendrán mediante una query de GraphQL que una las entidades `Timesheet`, `Shift`, `Event`, `Client`, etc.
### Recomendaciones
La recomendación principal es **crear una nueva entidad `Timesheet`**.
**1. Crear nuevo archivo**: `dataconnect/schema/timesheet.gql`
**2. Añadir el siguiente schema**:
```graphql
# NUEVO ARCHIVO: dataconnect/schema/timesheet.gql
enum TimesheetStatus {
PENDING # El trabajador lo ha enviado, pendiente de aprobación
APPROVED # Aprobado por un manager, listo para ser pagado
DISPUTED # El trabajador o manager ha marcado una discrepancia
PAID # Incluido en una nómina y pagado
}
type Timesheet @table(name: "timesheets") {
id: UUID! @default(expr: "uuidV4()")
shiftId: UUID! # FK a la tabla Shift
staffId: UUID! # FK a la tabla Staff
actualStart: Timestamp # Hora de entrada real registrada
actualEnd: Timestamp # Hora de salida real registrada
totalHours: Float # Horas totales calculadas
hourlyRate: Float # Tarifa por hora (puede copiarla del turno por si cambia)
totalPay: Float # Pago total calculado (totalHours * hourlyRate)
status: TimesheetStatus! @default(expr: "'PENDING'")
notes: String # Notas del trabajador o del manager
createdDate: Timestamp @default(expr: "request.time")
updatedDate: Timestamp @default(expr: "request.time")
createdBy: String @default(expr: "auth.uid")
}
```
**Consideraciones Adicionales**:
* **Relación**: La entidad `Timesheet` tendría una relación de uno-a-uno (o uno-a-muchos, si un turno puede tener varios registros de tiempo) con la entidad `Shift`.
* **Automatización**: La lógica para calcular `totalHours` y `totalPay` podría vivir en el backend (una Cloud Function que se dispara al crear/modificar un `Timesheet`) para asegurar consistencia.
* **Alineación en la App**: La app debe ser actualizada para hacer una query a `timesheets` y, a través de GraphQL, pedir los campos relacionados del turno (`shift { shiftName event { client { name } } }`).
---

View File

@@ -0,0 +1,189 @@
# Análisis de Mocks de Staff App vs. Schema de Data Connect
Este documento realiza una comparación detallada entre las estructuras de datos (mocks) utilizadas en la aplicación de Staff y las entidades definidas en el schema de GraphQL de Firebase Data Connect. El objetivo es identificar discrepancias, proponer soluciones y asegurar la consistencia entre el frontend y el backend.
---
## 1. Perfil de Leaderboard (`_profiles`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart`
- **Entidad Data Connect más cercana:** `Staff` (`dataconnect/schema/staff.gql`)
### Comparación Campo por Campo
| Campo (Mock App) | Campo (Schema GQL `Staff`) | Recomendación |
| :--- | :--- | :--- |
| `id` | `id` | ✅ **Match.** |
| `name` | `employeeName` | ⚠️ **Diferencia de nombre.** Renombrar `name` a `employeeName` en la app. |
| `photo_url` | `(No existe)` | ❌ **Campo faltante.** El schema de `Staff` no tiene un campo para URL de foto. Se debe agregar. |
| `xp` | `(No existe)` | ❌ **Campo faltante.** Relacionado con gamificación. Proponer `experiencePoints: Int`. |
| `level` | `(No existe)` | ❌ **Campo faltante.** Relacionado con gamificación. Proponer `level: String`. |
| `user_id` | `(No existe)` | ⚠️ **Diferencia conceptual.** El schema `Staff` tiene `vendorId` y `email`. Aclarar si `user_id` corresponde al `id` del `User` o `Staff`. |
### Recomendaciones
1. **En la app (Flutter):**
- Cambiar el nombre del campo `name` a `employeeName` para que coincida con el schema.
2. **En el schema (`staff.gql`):**
- Agregar los campos para la gamificación y el perfil.
```graphql
type Staff @table(name: "staffs") {
# ... campos existentes
profilePictureUrl: String
experiencePoints: Int @default(expr: "0")
level: String
}
```
---
## 2. Conversaciones (`_conversations`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/support/messages_screen.dart`
- **Entidades Data Connect más cercanas:** `Conversation` (`conversation.gql`) y `Message` (`message.gql`).
El mock aplana la información de ambas entidades.
### Comparación de `Conversation`
| Campo (Mock App) | Campo (Schema GQL `Conversation`) | Recomendación |
| :--- | :--- | :--- |
| `sender_id` | `participants` | ⚠️ **Diferencia de estructura.** El mock asume una conversación 1-a-1, mientras `participants` es un `String` (JSON) con un array de IDs. La app debe adaptarse para manejar múltiples participantes. |
| `sender_name` | (No existe directamente) | **Dato derivado.** Este dato debe obtenerse consultando la entidad `Staff` o `User` con el ID del participante. |
| `lastMessage` | (No existe directamente) | **Dato derivado.** Este debe ser el campo `content` del último `Message` asociado a la `conversationId`. La app debe consultar los mensajes. |
| `lastTime` | `updatedDate` | ✅ **Match conceptual.** `updatedDate` en `Conversation` debería actualizarse con cada nuevo mensaje. La app puede usar este campo. |
| `unread` | (No existe) | ❌ **Campo faltante y complejo.** El conteo de no leídos es por usuario. El schema actual no lo soporta. El campo `readBy` en `Message` podría usarse para calcular esto, pero es ineficiente. Se requiere un rediseño. |
| `messages` | (Corresponde a la entidad `Message`) | **Relación.** La app debe hacer una query separada para obtener los `Message` donde `conversationId` coincida. |
### Comparación de `Message` (anidado en el mock)
| Campo (Mock App) | Campo (Schema GQL `Message`) | Recomendación |
| :--- | :--- | :--- |
| `content` | `content` | ✅ **Match.** |
| `sender_id` | `createdBy` / `senderName` | ✅ **Match conceptual.** El schema tiene `createdBy` (ID del usuario) y `senderName`. La app debe usar estos campos. |
### Recomendaciones
1. **En la app (Flutter):**
- Adaptar la lógica para manejar `participants` como un array.
- Implementar la obtención de `sender_name` y `lastMessage` a través de queries adicionales.
- Eliminar la consulta de mensajes anidada y realizar una consulta separada por `conversationId`.
2. **En el schema (`conversation.gql` y `message.gql`):**
- El manejo de `unread` es un desafío. Una solución a largo plazo podría ser una nueva tabla `ConversationReadStatus` que vincule `(userId, conversationId, lastReadTimestamp)`. Por ahora, se puede calcular en el cliente.
- El schema actual es funcional pero requiere que el cliente orqueste varias llamadas.
---
## 3. Hojas de Tiempo (`_timesheets`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/finances/time_card_screen.dart`
- **Entidades Data Connect más cercanas:** `Shift` (`shift.gql`) y `Event` (`event.gql`).
El mock es una vista aplanada que combina información de un turno (`Shift`) y del evento (`Event`) al que pertenece.
### Comparación Campo por Campo
| Campo (Mock App) | Campo (Schema GQL `Shift`/`Event`) | Recomendación |
| :--- | :--- | :--- |
| `id` | `Shift.id` | ✅ **Match.** |
| `shift_id` | `Shift.id` | ✅ **Match.** (Redundante en el mock). |
| `date` | `Shift.startDate` | ✅ **Match conceptual.** Usar `startDate`. |
| `actual_start` | (No existe) | ❌ **Campo faltante.** El schema de `Shift` necesita campos para el check-in/out real. Proponer `actualStartTime: Timestamp`. |
| `actual_end` | (No existe) | ❌ **Campo faltante.** Proponer `actualEndTime: Timestamp`. |
| `total_hours` | (No existe) | **Dato derivado.** Debe calcularse en la app o en el backend a partir de `actualStartTime` y `actualEndTime`. |
| `hourly_rate` | `Staff.rate` / `Event.rate`? | ⚠️ **Requiere clarificación.** La tarifa puede depender del staff, del evento o del rol en el turno. El schema de `Shift` debería tener un campo `rate`. |
| `total_pay` | (No existe) | **Dato derivado.** Calcular `total_hours * hourly_rate`. |
| `status` | `Shift.status`? | ⚠️ **Ambigüedad.** `Shift` no tiene `status`, pero `Event` sí. El estado del pago (`pending`, `approved`, `paid`) es un concepto diferente. Se necesita un campo `paymentStatus` en `Shift`. |
| `shift_title` | `Shift.shiftName` | ✅ **Match.** |
| `client_name` | `Event.clientName` o `Business.name` | **Dato relacionado.** Se debe obtener a través del `eventId` en el `Shift` para luego consultar el `Event` o `Business` asociado. |
| `location` | `Event.eventLocation` | **Dato relacionado.** Obtener del `Event` asociado. |
### Recomendaciones
1. **En la app (Flutter):**
- La app debe realizar una consulta que una datos de `Shift` y su `Event` relacionado para construir esta vista.
- Calcular campos derivados como `total_hours` y `total_pay`.
2. **En el schema (`shift.gql`):**
- Agregar campos cruciales para el registro de tiempo y pago.
```graphql
type Shift @table(name: "shifts") {
# ... campos existentes
actualStartTime: Timestamp
actualEndTime: Timestamp
rate: Float
paymentStatus: String # O un enum: PENDING, APPROVED, PAID, DISPUTED
}
```
---
## 4. Certificados (`_certificates`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/compliance/certificates_screen.dart`
- **Entidad Data Connect más cercana:** `Certification` (`certification.gql`)
### Comparación Campo por Campo
| Campo (Mock App) | Campo (Schema GQL `Certification`) | Recomendación |
| :--- | :--- | :--- |
| `id` | `id` | ✅ **Match.** |
| `name` | `certificationName` | ⚠️ **Diferencia de nombre.** Renombrar `name` a `certificationName` en la app. |
| `icon` / `color` | (No existe) | **Lógica de UI.** Estos campos son para la visualización y deben manejarse en el frontend. No pertenecen al schema. |
| `description` | (No existe) | ❌ **Campo faltante.** El schema podría beneficiarse de un campo descriptivo. Proponer `description: String`. |
| `status` | `status` y `validationStatus` | ✅ **Match conceptual.** El schema tiene dos campos de estado. La app debe combinar o decidir cuál mostrar. `CertificationStatus` (`CURRENT`, `EXPIRED`) parece el correcto. |
| `expiry` | `expiryDate` | ✅ **Match conceptual.** El tipo de dato debe ser consistente (el mock usa String?, el schema `String!`). |
### Recomendaciones
1. **En la app (Flutter):**
- Cambiar `name` por `certificationName`.
- Mapear los valores de `status` del schema al estado que la UI espera.
2. **En el schema (`certification.gql`):**
- Considerar añadir un campo opcional para la descripción.
```graphql
type Certification @table(name: "certification") {
# ... campos existentes
description: String
}
```
---
## 5. Contactos de Emergencia (`_contacts`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart`
- **Entidad Data Connect más cercana:** **Ninguna.**
No existe una entidad para almacenar contactos de emergencia.
### Recomendaciones
1. **En el schema:**
- Crear una nueva entidad `EmergencyContact`. Debería estar vinculada a un `Staff`.
```graphql
# En un nuevo archivo: dataconnect/schema/emergencyContact.gql
type EmergencyContact @table(name: "emergency_contacts") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID! # FK a la tabla Staff
name: String!
phone: String!
relationship: String # Ejemplo: "Spouse", "Parent", "Friend"
createdDate: Timestamp @default(expr: "request.time")
updatedDate: Timestamp @default(expr: "request.time")
}
```
- Se debe decidir si esta información se almacena como un array de `jsonb` en la tabla `Staff` o como una tabla separada. Una tabla separada es más limpia y escalable.
---
## Conclusiones Generales
1. **Aplanamiento de Datos:** La app de Flutter tiende a usar estructuras de datos aplanadas que combinan campos de múltiples entidades de Data Connect. Esto es normal para las vistas de UI, pero requiere que la capa de datos de la app realice las consultas y uniones necesarias.
2. **Campos Faltantes Críticos:** Se han identificado campos faltantes importantes, especialmente en `Shift` (para el control de horas y pagos) y `Staff` (para gamificación y perfil).
3. **Nomenclatura:** Hay inconsistencias menores en los nombres de los campos. La recomendación general es **alinear la app con los nombres del schema de Data Connect**, que es la fuente de verdad.
4. **Nuevas Entidades:** Se necesitan nuevas entidades como `EmergencyContact`. Otras estructuras mock (`_benefitsData`, `_attireOptions`, `_courses`, etc.) también requerirán probablemente sus propias tablas y schemas en Data Connect.

View File

@@ -0,0 +1,333 @@
# Análisis Comparativo: Mocks de Staff App vs. Schema de Data Connect
Este documento detalla el análisis comparativo entre las estructuras de datos mock utilizadas en la aplicación de Staff y las entidades definidas en el schema de Firebase Data Connect.
El objetivo es identificar discrepancias, proponer alineaciones y asegurar la consistencia del modelo de datos.
---
## 1. `_calculateDuration()` (de `lib/widgets/shift_card.dart`)
- **Estructura Mock:**
```dart
{
'hours': int,
'breakTime': String,
}
```
- **Entidad Data Connect más cercana:** No aplica.
- **Análisis:**
- Esta estructura es un tipo de retorno de una función de utilidad en la UI, no representa una entidad persistente.
- Calcula valores derivados (`horas`, `breakTime`) a partir de los datos de un turno (`Shift`).
- **Recomendación:**
- No se requiere ninguna acción. Es correcto que la lógica de la UI maneje este tipo de cálculos derivados.
---
## 2. `createWorkerProfile` y `_profile` (Múltiples archivos)
- **Estructura Mock (agregada):**
```dart
{
'full_name': String, // Corresponde a 'employeeName' en Staff
'bio': String, // No existe en Staff
'preferred_locations': List<String>, // Podría ser 'hubLocation' o 'eventLocation'
'max_distance_miles': double, // No existe en Staff
'skills': List<String>, // No existe en Staff, ¿quizás 'track' o 'position'?
'industries': List<String>, // No existe en Staff
'level': String, // No existe en Staff
'photo_url': String, // No existe en Staff
'total_shifts': int, // Existe como 'totalShifts'
'average_rating': double, // Existe como 'rating'
'on_time_rate': int, // No existe en Staff
'no_show_count': int, // Existe como 'noShowCount'
'cancellation_count': int, // Existe como 'cancellationCount'
'reliability_score': int, // Existe como 'reliabilityScore'
'phone': String, // Existe como 'phone'
}
```
- **Entidad Data Connect más cercana:** `Staff` (`staff.gql`)
- **Comparación Campo por Campo:**
| Campo Mock | Campo `Staff` (Data Connect) | Coincidencia | Notas |
| --- | --- | --- | --- |
| `full_name` | `employeeName` | Parcial | El nombre es diferente pero el propósito es el mismo. |
| `bio` | `notes` | Parcial | `notes` es un campo genérico, `bio` es más específico. |
| `preferred_locations` | `hubLocation`, `eventLocation` | Parcial | El schema tiene campos de texto, el mock una lista. |
| `max_distance_miles`| - | No | No existe un campo equivalente. |
| `skills` | `track`, `position`, `position2`| Parcial | `skills` es una lista, mientras que el schema tiene campos fijos. |
| `industries` | - | No | No existe un campo equivalente. |
| `level` | `profileType` | Parcial | `level` es un string, `profileType` es un `Enum`. El propósito es similar. |
| `photo_url` | - | No | No existe un campo para la foto. |
| `total_shifts` | `totalShifts` | **Directa** | Coincidencia exacta. |
| `average_rating` | `rating` | **Directa** | El propósito es el mismo. |
| `on_time_rate` | - | No | No existe un campo equivalente. |
| `no_show_count` | `noShowCount` | **Directa** | Coincidencia exacta. |
| `cancellation_count`| `cancellationCount` | **Directa** | Coincidencia exacta. |
| `reliability_score`| `reliabilityScore` | **Directa** | Coincidencia exacta. |
| `phone` | `phone`, `contactNumber` | **Directa** | Existen dos campos de teléfono. Alinear a uno. |
- **Diferencias y Recomendaciones:**
1. **Nombres de Campos:** Se recomienda que la app **se alinee con los nombres de Data Connect** para mantener la consistencia (e.g., usar `employeeName` en lugar de `full_name`).
2. **Campos Faltantes en `Staff`:**
- `photo_url`: **Crítico.** Se debe agregar un campo `photoUrl: String` a la entidad `Staff`.
- `bio`: Se puede usar el campo `notes` o agregar un campo `bio: String` si se considera una propiedad fundamental.
- `max_distance_miles`: Recomendar agregar `maxTravelDistance: Float`.
- `on_time_rate`: Recomendar agregar `onTimeRate: Float`.
3. **Manejo de Listas (skills, industries, preferred_locations):**
- **Opción A (Recomendada):** Usar campos de tipo `jsonb` para almacenar estas listas.
```graphql
scalar Any
# En Staff
skills: Any @col(dataType: "jsonb")
industries: Any @col(dataType: "jsonb")
preferredLocations: Any @col(dataType: "jsonb")
```
- **Opción B:** Crear nuevas entidades y relaciones (e.g., `StaffSkill`, `StaffIndustry`), lo cual es más complejo pero más estructurado.
---
## 3. `_user` (de `worker_profile_screen.dart` y `personal_info_screen.dart`)
- **Estructura Mock:**
```dart
{
'full_name': String,
'email': String,
'photo_url': String,
}
```
- **Entidad Data Connect más cercana:** `User` (`user.gql`)
- **Comparación Campo por Campo:**
| Campo Mock | Campo `User` (Data Connect) | Coincidencia | Notas |
| --- | --- | --- | --- |
| `full_name` | `fullName` | **Directa** | Coincidencia de propósito, diferencia de naming. |
| `email` | `email` | **Directa** | Coincidencia exacta. |
| `photo_url` | - | No | No existe un campo para la foto en `User`. |
- **Diferencias y Recomendaciones:**
- El mock `_user` parece ser una combinación de datos de la entidad `User` (auth) y `Staff` (perfil).
- `photo_url` pertenece al perfil del trabajador, no a su registro de autenticación. Debería estar en la entidad `Staff` (ver punto anterior).
- La app debe obtener el `email` y `fullName` de la entidad `User` y el resto de la información de perfil de la entidad `Staff`.
---
## 4. `_contacts` (de `emergency_contact_screen.dart`)
- **Estructura Mock:**
```dart
{
'name': String,
'phone': String,
'relationship': String
}
```
- **Entidad Data Connect más cercana:** No existe.
- **Análisis:**
- Esta información es específica del perfil de un trabajador.
- No justifica una nueva tabla en la base de datos, ya que un contacto de emergencia no es una entidad independiente.
- **Recomendación:**
- Agregar un campo `emergencyContacts: Any @col(dataType: "jsonb")` a la entidad `Staff`. Este campo almacenaría un array de objetos JSON con la estructura del mock.
---
## 5. `_conversations` y `messages` (de `messages_screen.dart`)
- **Estructura Mock:**
```dart
// Conversación
{
'sender_id': String,
'sender_name': String,
'lastMessage': String,
'lastTime': DateTime,
'unread': int,
'messages': [ ... ] // Lista de mensajes
}
// Mensaje
{
'content': String,
'sender_id': String,
}
```
- **Entidades Data Connect más cercanas:** `Conversation` (`conversation.gql`) y `Message` (`message.gql`).
- **Análisis y Comparación:**
- Data Connect tiene un modelo normalizado con dos entidades separadas: `Conversation` y `Message`, relacionadas por `conversationId`.
- El mock presenta un modelo desnormalizado, donde los mensajes están anidados dentro de la conversación.
- **Discrepancias:**
- `Conversation`:
- El mock tiene `sender_id` y `sender_name`, mientras que el schema tiene `participants` (un array de IDs).
- El mock tiene `lastMessage`, `lastTime` y `unread`, que son datos derivados. El schema de `Conversation` no los tiene, y deberían calcularse o recuperarse del último mensaje asociado.
- `Message`:
- El schema tiene `senderName` y `createdBy` (ID), mientras que el mock solo tiene `sender_id`.
- **Recomendaciones:**
1. **Alinear la App:** La app debe adaptarse al modelo de Data Connect.
- Primero, obtener la lista de `Conversation` para el usuario actual.
- Luego, para cada conversación, obtener el último `Message` para mostrar `lastMessage` y `lastTime`.
- El conteo de `unread` deberá calcularse en la app o agregar un campo derivado en `Conversation`.
2. **Schema `Message`:** El schema de `Message` está bien, pero podría beneficiarse de un campo `senderId` explícito además de `createdBy` para mayor claridad.
---
## 6. `_timesheets` (de `time_card_screen.dart`)
- **Estructura Mock:**
```dart
{
'id': String,
'shift_id': String,
'date': String,
'actual_start': String,
'actual_end': String,
'total_hours': double,
'hourly_rate': double,
'total_pay': double,
'status': String,
'shift_title': String,
'client_name': String,
'location': String,
}
```
- **Entidad Data Connect más cercana:** `Assignment` (`assignment.gql`) y `Shift` (`shift.gql`).
- **Análisis:**
- Un `timesheet` parece ser el resultado de un `Assignment` completado.
- Combina información de múltiples entidades: el `Assignment` (estado, rol), el `Shift` (fechas, título) y `Staff` (tarifa/rate).
- **Recomendaciones:**
1. **Crear una nueva entidad `TimeCard`:** Esta es la opción más limpia para representar una hoja de tiempo.
```graphql
type TimeCard @table(name: "time_cards") {
id: UUID! @default(expr: "uuidV4()")
assignmentId: UUID!
staffId: UUID!
actualStart: Timestamp!
actualEnd: Timestamp!
totalHours: Float!
hourlyRate: Float!
totalPay: Float!
status: String # Podría ser un Enum (PENDING, APPROVED, PAID)
# ... otros campos relevantes
}
```
2. **Extender `Assignment`:** Agregar los campos `actualStart`, `actualEnd`, `totalHours`, etc., a la entidad `Assignment`. Esto es menos ideal, ya que mezcla la planificación (`scheduledStart`) con la ejecución.
3. **App:** La app deberá construir la vista del `timesheet` combinando datos de `TimeCard` (o `Assignment` extendido), `Shift` y `Order` (para `client_name`).
---
## 7. `_certificates` y `_courses` (Múltiples archivos)
- **Estructura Mock (agregada):**
```dart
{
'id': String,
'name': String, // o 'title'
'description': String,
'status': String, // 'CURRENT', 'EXPIRED', 'PENDING'
'expiry': String?,
'duration_minutes': int,
'xp_reward': int,
'progress_percent': int,
'completed': bool,
}
```
- **Entidad Data Connect más cercana:** `Certification` (`certification.gql`).
- **Análisis y Comparación:**
- La entidad `Certification` de Data Connect ya cubre los aspectos más importantes: nombre, tipo, estado y fecha de expiración.
- Los mocks `_courses` y `_trainings` introducen conceptos de gamificación (`xp_reward`) y seguimiento del progreso (`progress_percent`, `duration_minutes`) que no están en `Certification`.
- **Recomendaciones:**
1. **Diferenciar Entidades:** Es probable que se necesiten dos entidades:
- `Certification`: Para documentos oficiales y de cumplimiento (la entidad actual funciona bien).
- `TrainingCourse`: Una nueva entidad para los cursos de "Krow University".
```graphql
type TrainingCourse @table(name: "training_courses") {
id: UUID! @default(expr: "uuidV4()")
title: String!
description: String
durationMinutes: Int
xpReward: Int
# ... otros campos
}
type StaffTrainingProgress @table(name: "staff_training_progress") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
courseId: UUID!
progressPercent: Int
completed: Boolean
}
```
2. **App:** La app deberá consumir de `Certification` para la pantalla de "Certificados" y de las nuevas entidades para "Krow University".
---
## 8. `_recentPayments` (de `payments_screen.dart` y `earnings_screen.dart`)
- **Estructura Mock:**
```dart
{
'date': String,
'amount': double,
'status': String,
'title': String, // Nombre del turno/evento
'location': String,
'workedTime': String,
'hours': int,
'rate': int,
}
```
- **Entidad Data Connect más cercana:** `Invoice` (`invoice.gql`).
- **Análisis:**
- La entidad `Invoice` representa una factura con un monto total y un estado, pero no el detalle de los turnos que la componen.
- El mock `_recentPayments` es una vista agregada que combina datos de un pago/factura con detalles del trabajo realizado.
- **Recomendaciones:**
- El modelo de datos debería incluir una relación entre `Invoice` y los `TimeCard` (o `Assignment`) que la componen.
```graphql
# En la nueva entidad TimeCard
invoiceId: UUID # Se asigna cuando se incluye en una factura
```
- La app deberá:
1. Obtener los `Invoice` pagados para un `Staff`.
2. Para cada `Invoice`, obtener los `TimeCard` asociados.
3. Con los `TimeCard`, obtener los detalles del `Shift` y `Order` para construir la vista completa que se ve en el mock.
---
## 9. `_recentActivity` (de `clock_in_screen.dart`)
- **Estructura Mock:**
```dart
{
'date': DateTime,
'start': String,
'end': String,
'hours': String,
}
```
- **Entidad Data Connect más cercana:** `ActivityLog` (`activityLog.gql`)
- **Análisis:**
- La entidad `ActivityLog` está diseñada para registrar eventos genéricos (`EVENT_CREATED`, `STAFF_ASSIGNED`), pero no específicamente los eventos de "clock-in" y "clock-out".
- Los datos del mock (`start`, `end`, `hours`) se solapan con la entidad `TimeCard` propuesta anteriormente.
- **Recomendaciones:**
1. **Usar `TimeCard`:** Los eventos de `clock-in` y `clock-out` deberían **crear o actualizar** un registro en la entidad `TimeCard`.
- `Clock-in` → Crea un `TimeCard` con `actualStart`.
- `Clock-out` → Actualiza el `TimeCard` existente con `actualEnd` y calcula `totalHours`.
2. **`ActivityLog` para Notificaciones:** Se puede usar `ActivityLog` para *notificar* al usuario sobre acciones importantes (e.g., "Tu hoja de tiempo ha sido aprobada"), pero no para almacenar los datos crudos del fichaje.
3. La app debería mostrar el historial de `TimeCard` en la pantalla de `clock_in`.
---
## Conclusión General y Pasos a Seguir
La validación revela que, si bien hay una base sólida, se necesitan varias actualizaciones en el schema de Data Connect para dar soporte completo a las funcionalidades de la app de Staff.
**Acciones recomendadas (en orden de prioridad):**
1. **Actualizar `Staff`:**
- Añadir campos faltantes: `photoUrl`, `maxTravelDistance`, `onTimeRate`, `bio`.
- Añadir campos `jsonb` para `skills`, `industries`, `preferredLocations` y `emergencyContacts`.
- Alinear el naming en la app a `employeeName`.
2. **Crear `TimeCard`:**
- Definir e implementar la nueva entidad `TimeCard` para gestionar los fichajes y las horas trabajadas.
3. **Crear Entidades de `Training`:**
- Crear `TrainingCourse` y `StaffTrainingProgress` para la funcionalidad de "Krow University".
4. **Alinear la App:**
- Refactorizar los servicios de la app para que consuman el SDK de Data Connect según el modelo de datos actualizado (especialmente para conversaciones y pagos).
- Ajustar la UI para construir las vistas a partir de múltiples entidades relacionadas en lugar de un único mock desnormalizado.

View File

@@ -0,0 +1,294 @@
# Análisis Exhaustivo de Mocks de Staff App vs. Schema de Data Connect (V2)
Este documento realiza una comparación detallada y completa entre todas las estructuras de datos (mocks) utilizadas en la aplicación de Staff y las entidades definidas en el schema de GraphQL de Firebase Data Connect. El objetivo es asegurar la consistencia total y planificar la evolución del schema.
---
## 1. Perfil de Leaderboard (`_profiles`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/level_up/leaderboard_screen.dart`
- **Entidad Data Connect:** `Staff` (`dataconnect/schema/staff.gql`)
- **Análisis:** Existe una correspondencia directa pero incompleta.
- **Recomendaciones:**
- **App:** Renombrar `name` a `employeeName`.
- **Schema `staff.gql`:** Agregar campos para gamificación y perfil.
```graphql
type Staff @table(name: "staffs") {
# ... campos existentes
profilePictureUrl: String
experiencePoints: Int @default(expr: "0")
level: String
}
```
---
## 2. Conversaciones (`_conversations`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/support/messages_screen.dart`
- **Entidades Data Connect:** `Conversation` (`conversation.gql`) y `Message` (`message.gql`).
- **Análisis:** El mock es una vista aplanada de ambas entidades. El schema es más robusto pero requiere que la app orqueste las llamadas.
- **Recomendaciones:**
- **App:**
- Adaptar la lógica para manejar el campo `participants` (JSON array) en lugar de un `sender_id` simple.
- Obtener `sender_name` y `lastMessage` a través de consultas adicionales (al perfil del participante y al último mensaje).
- El campo `unread` es el más complejo. Debe calcularse en el cliente revisando la lista de `readBy` en cada mensaje.
- **Schema:** Para optimizar el conteo de no leídos a futuro, se podría crear una tabla `ConversationReadStatus (userId, conversationId, lastReadTimestamp)`.
---
## 3. Hojas de Tiempo (`_timesheets`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/finances/time_card_screen.dart`
- **Entidades Data Connect:** `Shift` (`shift.gql`) y `Event` (`event.gql`).
- **Análisis:** El mock combina información del turno y del evento. Faltan campos cruciales en el schema `Shift` para el registro de horas y pagos.
- **Recomendaciones:**
- **App:** Realizar una consulta que una datos de `Shift` y su `Event` relacionado para construir la vista.
- **Schema `shift.gql`:** Agregar los siguientes campos es **prioritario**.
```graphql
# En dataconnect/schema/shift.gql
enum ShiftPaymentStatus {
PENDING
APPROVED
PAID
DISPUTED
}
type Shift @table(name: "shifts") {
# ... campos existentes
actualStartTime: Timestamp
actualEndTime: Timestamp
rate: Float # Tarifa específica del turno
paymentStatus: ShiftPaymentStatus
}
```
---
## 4. Certificados (`_certificates`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/compliance/certificates_screen.dart`
- **Entidad Data Connect:** `Certification` (`certification.gql`).
- **Análisis:** Coincidencia conceptual fuerte.
- **Recomendaciones:**
- **App:** Renombrar `name` a `certificationName` y `expiry` a `expiryDate`.
- **Schema `certification.gql`:** Añadir un campo opcional `description: String`.
---
## 5. Contactos de Emergencia (`_contacts`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/onboarding/emergency_contact_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** No existe una entidad para esta información vital.
- **Recomendaciones:**
- **Schema:** Crear una nueva entidad `EmergencyContact` vinculada al `Staff`. Almacenarla en una tabla separada es preferible a un campo JSON en `Staff` por escalabilidad.
```graphql
# En dataconnect/schema/emergencyContact.gql
type EmergencyContact @table(name: "emergency_contacts") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID! # FK a la tabla Staff
name: String!
phone: String!
relationship: String # Ejemplo: "Spouse", "Parent"
createdDate: Timestamp @default(expr: "request.time")
}
```
---
## 6. Opciones de Vestimenta (`_attireOptions`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/onboarding/attire_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Representa el inventario de vestimenta de un trabajador, que puede ser requisito para un turno.
- **Recomendaciones:**
- **Schema:** Crear dos nuevas entidades: una para definir la vestimenta estándar y otra para asociarla a cada trabajador.
```graphql
# En dataconnect/schema/attire.gql
type Attire @table(name: "attire") {
id: UUID! @default(expr: "uuidV4()")
label: String!
imageUrl: String
}
# En dataconnect/schema/staffAttire.gql
type StaffAttire @table(name: "staff_attire") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
attireId: UUID!
}
```
---
## 7. Cursos de Capacitación (`_courses`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/level_up/trainings_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Distinto de `Certification`, se enfoca en capacitación interna y gamificación.
- **Recomendaciones:**
- **Schema:** Crear entidades para los cursos y el progreso de cada trabajador.
```graphql
# En dataconnect/schema/trainingCourse.gql
type TrainingCourse @table(name: "training_courses") {
id: UUID! @default(expr: "uuidV4()")
title: String!
description: String
durationMinutes: Int
xpReward: Int
}
# En dataconnect/schema/staffTraining.gql
type StaffTraining @table(name: "staff_trainings") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
courseId: UUID!
progressPercent: Int @default(expr: "0")
completed: Boolean @default(expr: "false")
}
```
---
## 8. Documentos Requeridos (`_requiredDocs` y `_forms`)
- **Archivos Mock:** `documents_screen.dart` y `tax_forms_screen.dart`.
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Ambos mocks se refieren a documentos de cumplimiento. Se pueden unificar.
- **Recomendaciones:**
- **Schema:** Crear una única entidad `StaffDocument` para manejar todos los documentos.
```graphql
# En dataconnect/schema/staffDocument.gql
enum DocumentType {
GOVERNMENT_ID
SSN
FORM_I9
FORM_W4
OTHER
}
enum DocumentStatus {
MISSING
PENDING_UPLOAD
PENDING_VERIFICATION
VERIFIED
REJECTED
}
type StaffDocument @table(name: "staff_documents") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
documentType: DocumentType!
status: DocumentStatus!
fileUrl: String # URL al archivo en Cloud Storage
notes: String # Para motivos de rechazo, etc.
}
```
---
## 9. Cuentas Bancarias (`_accounts`)
- **Archivo Mock:** `lib/screens/worker/worker_profile/finances/bank_account_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Esta es información altamente sensible. **No debe almacenarse directamente en la base de datos.** Debe ser manejada por un proveedor de pagos (ej. Stripe, Adyen).
- **Recomendaciones:**
- **Schema `staff.gql`:** Agregar un campo para almacenar el ID de cliente del proveedor de pagos, no los detalles de la cuenta.
```graphql
type Staff @table(name: "staffs") {
# ... campos existentes
paymentProviderCustomerId: String # ID del cliente en Stripe/Adyen
}
```
- **App:** Integrar el SDK del proveedor de pagos para registrar y mostrar la información bancaria de forma segura (tokenizada).
---
## 10. Pagos Recientes (`_recentPayments` - detallado y resumido)
- **Archivos Mock:** `payments_screen.dart` y `earnings_screen.dart`.
- **Entidad Data Connect:** **Ninguna (es una vista derivada).**
- **Análisis:** Estos mocks no representan una entidad, sino una vista de consulta sobre los `Shift` que han sido pagados.
- **Recomendaciones:**
- **Sin cambios en el schema,** asumiendo que las recomendaciones para `Shift` (punto 3) se implementen.
- **App:** La lógica para estas pantallas debe:
1. Consultar todos los `Shift` para el `staffId` con `paymentStatus: 'PAID'`.
2. Unir la información del `Event` relacionado para obtener la ubicación y nombre del cliente.
3. Para la vista de "Earnings", agrupar estos turnos por período de pago (ej. semanal) y sumar los montos.
---
## 11. Franjas Horarias (`_timeSlots` - Disponibilidad)
- **Archivo Mock:** `lib/screens/worker/availability_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Define la disponibilidad preferida del trabajador.
- **Recomendaciones:**
- **Schema:** Crear una nueva entidad `StaffAvailability`. Un campo JSON por día ofrece flexibilidad.
```graphql
# En dataconnect/schema/staffAvailability.gql
type StaffAvailability @table(name: "staff_availability") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
# Usar Any (jsonb) para guardar un array de strings: ["morning", "afternoon"]
monday: Any
tuesday: Any
wednesday: Any
thursday: Any
friday: Any
saturday: Any
sunday: Any
}
```
---
## 12. Datos de Beneficios (`_benefitsData`)
- **Archivo Mock:** `lib/screens/worker/benefits_screen.dart`
- **Entidad Data Connect:** **Ninguna.**
- **Análisis:** Gestiona los beneficios acumulados como días de vacaciones o enfermedad.
- **Recomendaciones:**
- **Schema:** Crear entidades para definir los tipos de beneficio y el balance de cada trabajador.
```graphql
# En dataconnect/schema/benefit.gql
enum BenefitType {
SICK_DAYS
VACATION
OTHER
}
type StaffBenefit @table(name: "staff_benefits") {
id: UUID! @default(expr: "uuidV4()")
staffId: UUID!
benefitType: BenefitType!
currentBalance: Float! # En horas o días
totalAllowance: Float! # Total anual/periodo
}
# Opcional: una tabla para el historial de uso
type BenefitHistory @table(name: "benefit_history") {
id: UUID! @default(expr: "uuidV4()")
staffBenefitId: UUID!
amount: Float! # Horas/días usados
date: Timestamp!
status: String # PENDING, APPROVED
}
```
---
## 13. Actividad Reciente (`ActivityLog`)
- **Mock:** No hay un mock específico, pero el concepto es universal.
- **Entidad Data Connect:** `ActivityLog` (`activityLog.gql`)
- **Análisis:** El schema `activityLog.gql` ya existe y es perfecto para registrar eventos importantes (ej. "Turno aceptado", "Perfil actualizado", "Certificado validado").
- **Recomendaciones:**
- **Backend:** Implementar la lógica para que las mutaciones importantes (actualizar staff, aceptar un turno, etc.) generen una entrada en la tabla `ActivityLog`.
- **App:** Crear una pantalla de "Actividad Reciente" que consulte esta tabla para el `staffId` logueado, mostrando un historial de eventos.
---
## 14. Pasos de Setup y FAQs (`_steps` y `_faqData`)
- **Archivos Mock:** `profile_setup_screen.dart` y `faqs_screen.dart`.
- **Análisis:** Estas estructuras son puramente para la UI (navegación de un wizard) o contienen contenido estático (FAQs).
- **Recomendaciones:**
- **Sin cambios en el schema.** Esta información no necesita ser gestionada a través de Data Connect. Puede permanecer como contenido estático dentro de la aplicación o, si requiere ser dinámico, ser gestionado por un CMS externo.