diff --git a/Makefile b/Makefile index e734abdb..eebde364 100644 --- a/Makefile +++ b/Makefile @@ -145,6 +145,23 @@ admin-build: @node scripts/patch-admin-layout-for-env-label.js @cd admin-web && VITE_APP_ENV=$(ENV) npm run build +# --- API Test Harness --- +harness-install: + @echo "--> Installing API Test Harness dependencies..." + @cd internal-api-harness && npm install + +harness-dev: + @echo "--> Starting API Test Harness development server on http://localhost:5175 ..." + @cd internal-api-harness && npm run dev -- --port 5175 + +harness-build: + @echo "--> Building API Test Harness for production..." + @cd internal-api-harness && npm run build -- --mode $(ENV) + +harness-deploy: harness-build + @echo "--> Deploying API Test Harness to [$(ENV)] environment..." + @firebase deploy --only hosting:api-harness-$(ENV) --project=$(FIREBASE_ALIAS) + deploy-admin: admin-build @echo "--> Building and deploying Admin Console to Cloud Run [$(ENV)]..." @echo " - Step 1: Building container image..." diff --git a/firebase.json b/firebase.json index 360ffbe7..8900d6b8 100644 --- a/firebase.json +++ b/firebase.json @@ -44,6 +44,38 @@ "destination": "/index.html" } ] + }, + { + "target": "api-harness-dev", + "public": "internal-api-harness/dist", + "site": "krow-api-harness-dev", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + }, + { + "target": "api-harness-staging", + "public": "internal-api-harness/dist", + "site": "krow-api-harness-staging", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] } ], "emulators": { diff --git a/firebase/internal-launchpad/index.html b/firebase/internal-launchpad/index.html index 2e3b73f0..9b8e22a3 100644 --- a/firebase/internal-launchpad/index.html +++ b/firebase/internal-launchpad/index.html @@ -93,6 +93,35 @@ #diagram-container:active { cursor: grabbing; } + + /* Accordion styles */ + .accordion-button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: #4b5563; /* text-gray-600 */ + text-align: left; + background-color: #f9fafb; /* bg-gray-50 */ + border-radius: 0.5rem; /* rounded-lg */ + transition: background-color 0.2s ease; + } + .accordion-button:hover { + background-color: #f3f4f6; /* bg-gray-100 */ + } + .accordion-button .chevron { + transition: transform 0.2s ease; + } + .accordion-button[aria-expanded="true"] .chevron { + transform: rotate(90deg); + } + .accordion-panel { + overflow: hidden; + transition: max-height 0.3s ease-out; + max-height: 0; + } /* Modal styles */ .modal-overlay { @@ -566,150 +595,96 @@ }); // Build hierarchical structure from paths - function buildDiagramHierarchy(diagrams) { - const hierarchy = {}; + function buildHierarchy(items, pathPrefix) { + const hierarchy = { _root: { _items: [], _children: {} } }; - diagrams.forEach(diagram => { - const parts = diagram.path.split('/'); - const relevantParts = parts.slice(2, -1); // Remove 'assets/diagrams/' and filename - - let current = hierarchy; - relevantParts.forEach(part => { - if (!current[part]) { - current[part] = { _items: [], _children: {} }; - } - current = current[part]._children; - }); - - // Add the item to appropriate level - if (relevantParts.length > 0) { - let parent = hierarchy[relevantParts[0]]; - for (let i = 1; i < relevantParts.length; i++) { - parent = parent._children[relevantParts[i]]; - } - parent._items.push(diagram); - } else { - // Root level diagrams - if (!hierarchy._root) { - hierarchy._root = { _items: [], _children: {} }; - } - hierarchy._root._items.push(diagram); + items.forEach(item => { + let relativePath = item.path; + if (relativePath.startsWith('./')) { + relativePath = relativePath.substring(2); } + if (relativePath.startsWith(pathPrefix)) { + relativePath = relativePath.substring(pathPrefix.length); + } + + const parts = relativePath.split('/'); + const relevantParts = parts.slice(0, -1); // remove filename + + let current = hierarchy._root; + relevantParts.forEach(part => { + if (!current._children[part]) { + current._children[part] = { _items: [], _children: {} }; + } + current = current._children[part]; + }); + current._items.push(item); }); - return hierarchy; } - // Build hierarchical structure from paths (for documents) - function buildDocumentHierarchy(documents) { - const hierarchy = {}; - - documents.forEach(doc => { - const parts = doc.path.split('/'); - const relevantParts = parts.slice(2, -1); // Remove 'assets/documents/' and filename - - let current = hierarchy; - relevantParts.forEach(part => { - if (!current[part]) { - current[part] = { _items: [], _children: {} }; - } - current = current[part]._children; - }); - - // Add the item to appropriate level - if (relevantParts.length > 0) { - let parent = hierarchy[relevantParts[0]]; - for (let i = 1; i < relevantParts.length; i++) { - parent = parent._children[relevantParts[i]]; - } - parent._items.push(doc); - } else { - // Root level documents - if (!hierarchy._root) { - hierarchy._root = { _items: [], _children: {} }; - } - hierarchy._root._items.push(doc); - } - }); - - return hierarchy; - } + // Generic function to create accordion navigation + function createAccordionNavigation(hierarchy, parentElement, createLinkFunction, sectionTitle) { + const createAccordion = (title, items, children) => { + const container = document.createElement('div'); + container.className = 'mb-1'; - // Create navigation from hierarchy - function createNavigation(hierarchy, parentElement, level = 0) { - // First, show root level items if any - if (hierarchy._root && hierarchy._root._items.length > 0) { - const mainHeading = document.createElement('div'); - mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3'; - mainHeading.textContent = 'Diagrams'; - parentElement.appendChild(mainHeading); + const button = document.createElement('button'); + button.className = 'accordion-button'; + button.setAttribute('aria-expanded', 'false'); + button.innerHTML = ` + ${title.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + `; + + const panel = document.createElement('div'); + panel.className = 'accordion-panel pl-4 pt-1'; - hierarchy._root._items.forEach(diagram => { - createDiagramLink(diagram, parentElement, 0); - }); - } - - // Then process nested categories - Object.keys(hierarchy).forEach(key => { - if (key === '_items' || key === '_children' || key === '_root') return; - - const section = hierarchy[key]; - const heading = document.createElement('div'); - heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' + - (level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8'); - heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); - parentElement.appendChild(heading); - - // Add items in this section - if (section._items && section._items.length > 0) { - section._items.forEach(diagram => { - createDiagramLink(diagram, parentElement, level); + if (items) { + items.forEach(item => createLinkFunction(item, panel, 1)); + } + + if (children) { + Object.keys(children).forEach(childKey => { + const childSection = children[childKey]; + const childHeading = document.createElement('div'); + childHeading.className = 'px-4 pt-2 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider'; + childHeading.textContent = childKey.replace(/-/g, ' '); + panel.appendChild(childHeading); + childSection._items.forEach(item => createLinkFunction(item, panel, 2)); }); } - - // Recursively add children - if (section._children && Object.keys(section._children).length > 0) { - createNavigation(section._children, parentElement, level + 1); - } - }); - } - // Create document navigation from hierarchy - function createDocumentNavigation(hierarchy, parentElement, level = 0) { - // First, show root level items if any - if (hierarchy._root && hierarchy._root._items.length > 0) { - const mainHeading = document.createElement('div'); - mainHeading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3'; - mainHeading.textContent = 'Documentation'; - parentElement.appendChild(mainHeading); - - hierarchy._root._items.forEach(doc => { - createDocumentLink(doc, parentElement, 0); + button.addEventListener('click', () => { + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + button.setAttribute('aria-expanded', !isExpanded); + if (!isExpanded) { + panel.style.maxHeight = panel.scrollHeight + 'px'; + } else { + panel.style.maxHeight = '0px'; + } }); + + container.appendChild(button); + container.appendChild(panel); + return container; + }; + + const heading = document.createElement('div'); + heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mt-6 mb-3'; + heading.textContent = sectionTitle; + parentElement.appendChild(heading); + + // Process root items first + if (hierarchy._root && hierarchy._root._items.length > 0) { + hierarchy._root._items.forEach(item => createLinkFunction(item, parentElement, 0)); } - - // Then process nested categories - Object.keys(hierarchy).forEach(key => { - if (key === '_items' || key === '_children' || key === '_root') return; - - const section = hierarchy[key]; - const heading = document.createElement('div'); - heading.className = 'px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider ' + - (level === 0 ? 'mt-6 mb-3' : 'mt-4 mb-2 pl-8'); - heading.textContent = key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); - parentElement.appendChild(heading); - - // Add items in this section - if (section._items && section._items.length > 0) { - section._items.forEach(doc => { - createDocumentLink(doc, parentElement, level); - }); - } - - // Recursively add children - if (section._children && Object.keys(section._children).length > 0) { - createDocumentNavigation(section._children, parentElement, level + 1); - } + + // Process categories as accordions + Object.keys(hierarchy._root._children).forEach(key => { + if (key.startsWith('_')) return; + const section = hierarchy._root._children[key]; + const accordion = createAccordion(key, section._items, section._children); + parentElement.appendChild(accordion); }); } @@ -717,8 +692,8 @@ function createDocumentLink(doc, parentElement, level) { const link = document.createElement('a'); link.href = '#'; - link.className = 'nav-item flex items-center space-x-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 mb-1' + - (level > 0 ? ' pl-8' : ''); + link.className = 'nav-item flex items-center space-x-3 px-4 py-2.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm mb-1' + + (level > 0 ? ' ' : ''); link.onclick = (e) => { e.preventDefault(); showView('document', link, doc.path, doc.title); @@ -728,7 +703,7 @@ `; - link.innerHTML = `${iconSvg}${doc.title}`; + link.innerHTML = `${iconSvg}${doc.title}`; parentElement.appendChild(link); } @@ -736,26 +711,18 @@ function createDiagramLink(diagram, parentElement, level) { const link = document.createElement('a'); link.href = '#'; - link.className = 'nav-item flex items-center space-x-3 px-4 py-3 rounded-xl text-gray-700 hover:bg-gray-50 mb-1' + - (level > 0 ? ' pl-8' : ''); + link.className = 'nav-item flex items-center space-x-3 px-4 py-2.5 rounded-lg text-gray-600 hover:bg-gray-100 text-sm mb-1' + + (level > 0 ? ' ' : ''); link.onclick = (e) => { e.preventDefault(); showView('diagram', link, diagram.path, diagram.title, diagram.type); }; - // Get icon based on type or custom icon - let iconSvg = ''; - if (diagram.type === 'svg') { - iconSvg = ` - - `; - } else { - iconSvg = ` - - `; - } + const iconSvg = ` + + `; - link.innerHTML = iconSvg + '' + diagram.title + ''; + link.innerHTML = `${iconSvg}${diagram.title}`; parentElement.appendChild(link); } @@ -770,12 +737,11 @@ } const text = await response.text(); - console.log('Loaded config:', text); allDiagrams = JSON.parse(text); if (allDiagrams && allDiagrams.length > 0) { - const hierarchy = buildDiagramHierarchy(allDiagrams); - createNavigation(hierarchy, dynamicSection); + const hierarchy = buildHierarchy(allDiagrams, 'assets/diagrams/'); + createAccordionNavigation(hierarchy, dynamicSection, createDiagramLink, 'Diagrams'); } } catch (error) { console.error('Error loading diagrams configuration:', error); @@ -801,12 +767,11 @@ } const text = await response.text(); - console.log('Loaded documents config:', text); allDocuments = JSON.parse(text); if (allDocuments && allDocuments.length > 0) { - const hierarchy = buildDocumentHierarchy(allDocuments); - createDocumentNavigation(hierarchy, documentationSection); + const hierarchy = buildHierarchy(allDocuments, 'assets/documents/'); + createAccordionNavigation(hierarchy, documentationSection, createDocumentLink, 'Documentation'); } } catch (error) { console.error('Error loading documents configuration:', error); diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 03a306e4..ededb861 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@base44/sdk": "^0.1.2", "@dataconnect/generated": "file:src/dataconnect-generated", + "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^4.1.2", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -1740,6 +1741,23 @@ "node": ">=6" } }, + "node_modules/@hello-pangea/dnd": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", + "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.7", + "css-box-model": "^1.2.1", + "raf-schd": "^4.0.3", + "react-redux": "^9.2.0", + "redux": "^5.0.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@hookform/resolvers": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", @@ -4491,6 +4509,12 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -5274,6 +5298,15 @@ "node": ">= 8" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "license": "MIT", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -8375,6 +8408,12 @@ ], "license": "MIT" }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8436,6 +8475,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -8653,6 +8715,12 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/frontend-web/package.json b/frontend-web/package.json index 9b5067ef..e6f98a78 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@base44/sdk": "^0.1.2", "@dataconnect/generated": "file:src/dataconnect-generated", + "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^4.1.2", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", diff --git a/frontend-web/src/api/entities.js b/frontend-web/src/api/entities.js index 797a7e85..26cbda33 100644 --- a/frontend-web/src/api/entities.js +++ b/frontend-web/src/api/entities.js @@ -63,6 +63,10 @@ export const TeamMemberInvite = base44.entities.TeamMemberInvite; export const VendorDocumentReview = base44.entities.VendorDocumentReview; +export const Task = base44.entities.Task; + +export const TaskComment = base44.entities.TaskComment; + // auth sdk: diff --git a/frontend-web/src/components/dashboard/DashboardCustomizer.jsx b/frontend-web/src/components/dashboard/DashboardCustomizer.jsx new file mode 100644 index 00000000..fc0cc12c --- /dev/null +++ b/frontend-web/src/components/dashboard/DashboardCustomizer.jsx @@ -0,0 +1,396 @@ +import React, { useState, useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Settings, + GripVertical, + X, + Plus, + Eye, + EyeOff, + Info, + Save, + RotateCcw, + Sparkles +} from "lucide-react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { useToast } from "@/components/ui/use-toast"; +import { motion, AnimatePresence } from "framer-motion"; + +export default function DashboardCustomizer({ + user, + availableWidgets = [], + currentLayout = [], + onLayoutChange, + dashboardType = "default" // admin, client, vendor, operator, etc +}) { + const [isOpen, setIsOpen] = useState(false); + const [showHowItWorks, setShowHowItWorks] = useState(false); + const [visibleWidgets, setVisibleWidgets] = useState([]); + const [hiddenWidgets, setHiddenWidgets] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + // Initialize widgets from user's saved layout or defaults + useEffect(() => { + const layoutKey = `dashboard_layout_${dashboardType}`; + const savedLayout = user?.[layoutKey]; + + if (savedLayout?.widgets && savedLayout.widgets.length > 0) { + const savedVisible = savedLayout.widgets + .map(id => availableWidgets.find(w => w.id === id)) + .filter(Boolean); + setVisibleWidgets(savedVisible); + + const savedHidden = savedLayout.hidden_widgets || []; + const hiddenWidgetsList = availableWidgets.filter(w => + savedHidden.includes(w.id) + ); + setHiddenWidgets(hiddenWidgetsList); + } else { + // Default: all widgets visible in provided order + setVisibleWidgets(availableWidgets); + setHiddenWidgets([]); + } + }, [user, availableWidgets, isOpen, dashboardType]); + + // Save layout mutation + const saveLayoutMutation = useMutation({ + mutationFn: async (layoutData) => { + const layoutKey = `dashboard_layout_${dashboardType}`; + await base44.auth.updateMe({ + [layoutKey]: layoutData + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['current-user'] }); + queryClient.invalidateQueries({ queryKey: ['current-user-layout'] }); + queryClient.invalidateQueries({ queryKey: ['current-user-client'] }); + queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] }); + queryClient.invalidateQueries({ queryKey: ['current-user-operator'] }); + toast({ + title: "✅ Layout Saved", + description: "Your dashboard layout has been updated", + }); + setHasChanges(false); + if (onLayoutChange) { + onLayoutChange(visibleWidgets); + } + setTimeout(() => { + setIsOpen(false); + }, 500); + }, + onError: () => { + toast({ + title: "❌ Save Failed", + description: "Could not save your layout. Please try again.", + variant: "destructive", + }); + } + }); + + const handleDragEnd = (result) => { + if (!result.destination) return; + + const { source, destination } = result; + + if (source.droppableId === "visible" && destination.droppableId === "visible") { + const items = Array.from(visibleWidgets); + const [reorderedItem] = items.splice(source.index, 1); + items.splice(destination.index, 0, reorderedItem); + setVisibleWidgets(items); + setHasChanges(true); + } + }; + + const handleHideWidget = (widget) => { + setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id)); + setHiddenWidgets([...hiddenWidgets, widget]); + setHasChanges(true); + }; + + const handleShowWidget = (widget) => { + setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id)); + setVisibleWidgets([...visibleWidgets, widget]); + setHasChanges(true); + }; + + const handleSave = () => { + const layoutData = { + widgets: visibleWidgets.map(w => w.id), + hidden_widgets: hiddenWidgets.map(w => w.id), + layout_version: "2.0" + }; + saveLayoutMutation.mutate(layoutData); + }; + + const handleReset = () => { + setVisibleWidgets(availableWidgets); + setHiddenWidgets([]); + setHasChanges(true); + }; + + const handleOpenCustomizer = () => { + setIsOpen(true); + setShowHowItWorks(true); + setHasChanges(false); + }; + + const handleClose = () => { + if (hasChanges) { + if (window.confirm("You have unsaved changes. Are you sure you want to close?")) { + setIsOpen(false); + setHasChanges(false); + } + } else { + setIsOpen(false); + } + }; + + return ( + <> + {/* Customize Button */} + + + {/* Customizer Dialog */} + + + + + + Customize Your Dashboard + + + Personalize your workspace by adding, removing, and reordering widgets + + + + {/* How It Works Banner */} + + {showHowItWorks && ( + +
+
+
+ +

How it works

+
+
+

+ + Drag widgets to reorder them +

+

+ + Hide widgets you don't need +

+

+ + Show hidden widgets to bring them back +

+
+
+ +
+
+ )} +
+ +
+ {/* Visible Widgets */} +
+
+

+ Visible Widgets ({visibleWidgets.length}) +

+ +
+ + + + {(provided, snapshot) => ( +
+ {visibleWidgets.length === 0 ? ( +
+ +

No visible widgets

+

Add widgets from the hidden section below!

+
+ ) : ( + visibleWidgets.map((widget, index) => ( + + {(provided, snapshot) => ( +
+
+
+ +
+
+

{widget.title}

+

{widget.description}

+
+ + {widget.category} + + +
+
+ )} +
+ )) + )} + {provided.placeholder} +
+ )} +
+
+
+ + {/* Hidden Widgets */} + {hiddenWidgets.length > 0 && ( +
+

+ Hidden Widgets ({hiddenWidgets.length}) + Click + to add +

+
+ {hiddenWidgets.map((widget) => ( +
+
+
+

{widget.title}

+

{widget.description}

+
+ +
+
+ ))} +
+
+ )} + + {/* All Hidden Message */} + {hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && ( +
+ +

+ All widgets are visible on your dashboard! +

+
+ )} +
+ + {/* Actions */} +
+
+ + {hasChanges && ( + + Unsaved Changes + + )} +
+
+ + +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend-web/src/components/events/AssignedStaffManager.jsx b/frontend-web/src/components/events/AssignedStaffManager.jsx new file mode 100644 index 00000000..384e3bfa --- /dev/null +++ b/frontend-web/src/components/events/AssignedStaffManager.jsx @@ -0,0 +1,235 @@ +import React, { useState } from "react"; +import { base44 } from "@/api/base44Client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/use-toast"; +import { Edit2, Trash2, ArrowLeftRight, Clock, MapPin, Check, X } from "lucide-react"; +import { format } from "date-fns"; + +export default function AssignedStaffManager({ event, shift, role }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [editTarget, setEditTarget] = useState(null); + const [swapTarget, setSwapTarget] = useState(null); + const [editTimes, setEditTimes] = useState({ start: "", end: "" }); + + // Get assigned staff for this role + const assignedStaff = (event.assigned_staff || []).filter( + s => s.role === role?.role + ); + + // Remove staff mutation + const removeMutation = useMutation({ + mutationFn: async (staffId) => { + const updatedAssignedStaff = (event.assigned_staff || []).filter( + s => s.staff_id !== staffId || s.role !== role.role + ); + + const updatedShifts = (event.shifts || []).map(s => { + if (s.shift_name === shift.shift_name) { + const updatedRoles = (s.roles || []).map(r => { + if (r.role === role.role) { + return { + ...r, + assigned: Math.max((r.assigned || 0) - 1, 0), + }; + } + return r; + }); + return { ...s, roles: updatedRoles }; + } + return s; + }); + + await base44.entities.Event.update(event.id, { + assigned_staff: updatedAssignedStaff, + shifts: updatedShifts, + requested: Math.max((event.requested || 0) - 1, 0), + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + toast({ + title: "✅ Staff Removed", + description: "Staff member has been removed from this assignment", + }); + }, + }); + + // Edit times mutation + const editMutation = useMutation({ + mutationFn: async () => { + const updatedShifts = (event.shifts || []).map(s => { + if (s.shift_name === shift.shift_name) { + const updatedRoles = (s.roles || []).map(r => { + if (r.role === role.role) { + return { + ...r, + start_time: editTimes.start, + end_time: editTimes.end, + }; + } + return r; + }); + return { ...s, roles: updatedRoles }; + } + return s; + }); + + await base44.entities.Event.update(event.id, { + shifts: updatedShifts, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + toast({ + title: "✅ Times Updated", + description: "Assignment times have been updated", + }); + setEditTarget(null); + }, + }); + + const handleEdit = (staff) => { + setEditTarget(staff); + setEditTimes({ + start: role.start_time || "09:00", + end: role.end_time || "17:00", + }); + }; + + const handleSaveEdit = () => { + editMutation.mutate(); + }; + + const handleRemove = (staffId) => { + if (confirm("Are you sure you want to remove this staff member?")) { + removeMutation.mutate(staffId); + } + }; + + if (!assignedStaff.length) { + return ( +
+ No staff assigned yet +
+ ); + } + + return ( + <> +
+ {assignedStaff.map((staff) => ( +
+ + + {staff.staff_name?.charAt(0) || 'S'} + + + +
+

{staff.staff_name}

+
+ + {staff.role} + + {role.start_time && role.end_time && ( + + + {role.start_time} - {role.end_time} + + )} +
+
+ +
+ + +
+
+ ))} +
+ + {/* Edit Times Dialog */} + setEditTarget(null)}> + + + Edit Assignment Times + + +
+
+ +

{editTarget?.staff_name}

+
+ +
+
+ + setEditTimes({ ...editTimes, start: e.target.value })} + /> +
+
+ + setEditTimes({ ...editTimes, end: e.target.value })} + /> +
+
+
+ + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/frontend-web/src/components/events/EventFormWizard.jsx b/frontend-web/src/components/events/EventFormWizard.jsx index 036ed484..c56d526c 100644 --- a/frontend-web/src/components/events/EventFormWizard.jsx +++ b/frontend-web/src/components/events/EventFormWizard.jsx @@ -40,6 +40,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current date: "", include_backup: false, backup_staff_count: 0, + vendor_id: "", // Added vendor_id + vendor_name: "", // Added vendor_name shifts: [{ shift_name: "Shift 1", location_address: "", @@ -72,6 +74,7 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current const currentUserData = currentUser || user; const userRole = currentUserData?.user_role || currentUserData?.role || "admin"; const isVendor = userRole === "vendor"; + const isClient = userRole === "client"; // Added isClient const { data: businesses = [] } = useQuery({ queryKey: ['businesses'], @@ -79,6 +82,12 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current initialData: [], }); + const { data: vendors = [] } = useQuery({ // Added vendors query + queryKey: ['vendors-for-order'], + queryFn: () => base44.entities.Vendor.list(), + initialData: [], + }); + const { data: allRates = [] } = useQuery({ queryKey: ['vendor-rates-all'], queryFn: () => base44.entities.VendorRate.list(), @@ -87,6 +96,22 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current const availableRoles = [...new Set(allRates.map(r => r.role_name))].sort(); + // Auto-select preferred vendor for clients + useEffect(() => { + if (isClient && currentUserData && !formData.vendor_id) { + const preferredVendorId = currentUserData.preferred_vendor_id; + const preferredVendorName = currentUserData.preferred_vendor_name; + + if (preferredVendorId) { + setFormData(prev => ({ + ...prev, + vendor_id: preferredVendorId, + vendor_name: preferredVendorName || "" + })); + } + } + }, [isClient, currentUserData, formData.vendor_id]); // Dependency array updated + useEffect(() => { if (event) { setFormData(event); @@ -112,6 +137,38 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current } }; + const handleVendorChange = (vendorId) => { // Added handleVendorChange + const selectedVendor = vendors.find(v => v.id === vendorId); + if (selectedVendor) { + setFormData(prev => { + const updatedShifts = prev.shifts.map(shift => ({ + ...shift, + roles: shift.roles.map(role => { + if (role.role) { + const rate = getRateForRole(role.role, vendorId); // Re-calculate rates with new vendorId + return { + ...role, + rate_per_hour: rate, + total_value: rate * (role.hours || 0) * (role.count || 1), + vendor_id: vendorId, + vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as + }; + } + return role; + }) + })); + + return { + ...prev, + vendor_id: vendorId, + vendor_name: selectedVendor.legal_name || selectedVendor.doing_business_as || "", + shifts: updatedShifts + }; + }); + updateGrandTotal(); + } + }; + const calculateHours = (startTime, endTime, breakMinutes = 0) => { if (!startTime || !endTime) return 0; const [startHour, startMin] = startTime.split(':').map(Number); @@ -124,9 +181,21 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current return Math.max(0, totalMinutes / 60); }; - const getRateForRole = (roleName) => { - const rate = allRates.find(r => r.role_name === roleName && r.is_active); - return rate ? parseFloat(rate.client_rate || 0) : 0; + const getRateForRole = (roleName, vendorId = null) => { // Modified getRateForRole + const targetVendorId = vendorId || formData.vendor_id; + + if (targetVendorId) { + const rate = allRates.find(r => + r.role_name === roleName && + r.vendor_id === targetVendorId && + r.is_active + ); + if (rate) return parseFloat(rate.client_rate || 0); + } + + // Fallback to any active rate if no specific vendor or vendor-specific rate is found + const fallbackRate = allRates.find(r => r.role_name === roleName && r.is_active); + return fallbackRate ? parseFloat(fallbackRate.client_rate || 0) : 0; }; const handleRoleChange = (shiftIndex, roleIndex, field, value) => { @@ -138,6 +207,8 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current if (field === 'role') { const rate = getRateForRole(value); role.rate_per_hour = rate; + role.vendor_id = prev.vendor_id; // Added vendor_id to role + role.vendor_name = prev.vendor_name; // Added vendor_name to role } if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') { @@ -176,7 +247,9 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current uniform: "Type 1", break_minutes: 30, // Default to 30 min non-payable rate_per_hour: 0, - total_value: 0 + total_value: 0, + vendor_id: prev.vendor_id, // Added vendor_id to new role + vendor_name: prev.vendor_name // Added vendor_name to new role }); return { ...prev, shifts: newShifts }; }); @@ -588,6 +661,36 @@ export default function EventFormWizard({ event, onSubmit, isSubmitting, current + {/* Vendor Selection for Clients */} + {isClient && ( +
+ + + {formData.vendor_id && ( +

+ ✓ Rates will be automatically applied from {formData.vendor_name} +

+ )} +
+ )} + {/* 1. Hub (first) */}
diff --git a/frontend-web/src/components/events/ShiftCard.jsx b/frontend-web/src/components/events/ShiftCard.jsx index 7626ea65..f7849584 100644 --- a/frontend-web/src/components/events/ShiftCard.jsx +++ b/frontend-web/src/components/events/ShiftCard.jsx @@ -1,84 +1,160 @@ -import React from "react"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +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 { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { MapPin, Plus } from "lucide-react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react"; +import SmartAssignModal from "./SmartAssignModal"; +import AssignedStaffManager from "./AssignedStaffManager"; -export default function ShiftCard({ shift, onNotifyStaff }) { +const convertTo12Hour = (time24) => { + if (!time24 || time24 === "—") return time24; + + try { + const parts = time24.split(':'); + if (!parts || parts.length < 2) return time24; + + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + + if (isNaN(hours) || isNaN(minutes)) return time24; + + const period = hours >= 12 ? 'PM' : 'AM'; + const hours12 = hours % 12 || 12; + const minutesStr = minutes.toString().padStart(2, '0'); + + return `${hours12}:${minutesStr} ${period}`; + } catch (error) { + console.error('Error converting time:', error); + return time24; + } +}; + +export default function ShiftCard({ shift, event }) { + const [assignModal, setAssignModal] = useState({ open: false, role: null }); + + const roles = shift?.roles || []; + return ( - - -
- {shift.shift_name || "Shift 1"} - -
-
-
-

Managers:

-
- {shift.assigned_staff?.slice(0, 3).map((staff, idx) => ( -
- - {staff.staff_name?.charAt(0)} - -
-

{staff.staff_name}

-

{staff.position || "john@email.com"}

-
-
- ))} -
-
-
- + <> + + +
-

Location:

-

{shift.location || "848 East Glen Road New York CA, USA"}

+ + {shift.shift_name || "Shift"} + + {shift.location && ( +
+ + {shift.location} +
+ )}
+ + {roles.length} Role{roles.length !== 1 ? 's' : ''} +
-
- - - - - - Unpaid break - Count - Assigned - Uniform type - Price - Amount - Actions - - - - {(shift.assigned_staff || []).length > 0 ? ( - shift.assigned_staff.map((staff, idx) => ( - - {shift.unpaid_break || 0} - 1 - 0 - {shift.uniform_type || "uniform type"} - ${shift.price || 23} - {shift.amount || 120} - - - - - )) - ) : ( - - - No staff assigned yet - - - )} - -
-
- + + + +
+ {roles.map((role, idx) => { + const requiredCount = role.count || 1; + const assignedCount = event?.assigned_staff?.filter(s => s.role === role.role)?.length || 0; + const remainingCount = Math.max(requiredCount - assignedCount, 0); + + // Consistent status color logic + const statusColor = remainingCount === 0 + ? "bg-green-100 text-green-700 border-green-300" + : assignedCount > 0 + ? "bg-blue-100 text-blue-700 border-blue-300" + : "bg-slate-100 text-slate-700 border-slate-300"; + + return ( +
+
+
+
+

{role.role}

+ + {assignedCount} / {requiredCount} Assigned + +
+ +
+ {role.start_time && role.end_time && ( + + + {convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)} + + )} + {role.department && ( + + {role.department} + + )} +
+
+ + {remainingCount > 0 && ( + + )} +
+ + {/* Show assigned staff */} + {assignedCount > 0 && ( +
+

+ Assigned Staff +

+ +
+ )} + + {/* Additional role details */} + {(role.uniform || role.cost_per_hour) && ( +
+ {role.uniform && ( +
+

Uniform

+

{role.uniform}

+
+ )} + {role.cost_per_hour && ( +
+ +
+

Rate

+

${role.cost_per_hour}/hr

+
+
+ )} +
+ )} +
+ ); + })} +
+
+ + + {/* Smart Assignment Modal */} + setAssignModal({ open: false, role: null })} + event={event} + shift={shift} + role={assignModal.role} + /> + ); } \ No newline at end of file diff --git a/frontend-web/src/components/events/SmartAssignModal.jsx b/frontend-web/src/components/events/SmartAssignModal.jsx new file mode 100644 index 00000000..14a51924 --- /dev/null +++ b/frontend-web/src/components/events/SmartAssignModal.jsx @@ -0,0 +1,878 @@ + +import React, { useState, useMemo, useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { useToast } from "@/components/ui/use-toast"; +import { + Search, + Users, + AlertTriangle, + Star, + MapPin, + Sparkles, + Check, + Calendar, + Sliders, + TrendingUp, + Shield, + DollarSign, + Zap, + Bell, +} from "lucide-react"; +import { format } from "date-fns"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Slider } from "@/components/ui/slider"; + +// Helper to check time overlap with buffer +function hasTimeOverlap(start1, end1, start2, end2, bufferMinutes = 30) { + const s1 = new Date(start1).getTime(); + const e1 = new Date(end1).getTime() + bufferMinutes * 60 * 1000; + const s2 = new Date(start2).getTime(); + const e2 = new Date(end2).getTime() + bufferMinutes * 60 * 1000; + + return s1 < e2 && s2 < e1; +} + +export default function SmartAssignModal({ open, onClose, event, shift, role }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [searchQuery, setSearchQuery] = useState(""); + const [selected, setSelected] = useState(new Set()); + const [sortMode, setSortMode] = useState("smart"); + const [selectedRole, setSelectedRole] = useState(null); // New state to manage current selected role for assignment + + // Smart assignment priorities + const [priorities, setPriorities] = useState({ + skill: 100, // Skill is implied by position match, not a slider + reliability: 80, + fatigue: 60, + compliance: 70, + proximity: 50, + cost: 40, + }); + + useEffect(() => { + if (open) { + setSelected(new Set()); + setSearchQuery(""); + + // Auto-select first role if available or the one passed in props + if (event && !role) { + // If no specific role is passed, find roles that need assignment + const initialRoles = []; + (event.shifts || []).forEach(s => { + (s.roles || []).forEach(r => { + const currentAssignedCount = event.assigned_staff?.filter(staff => + staff.role === r.role && staff.shift_name === s.shift_name + )?.length || 0; + if ((r.count || 0) > currentAssignedCount) { + initialRoles.push({ shift: s, role: r }); + } + }); + }); + if (initialRoles.length > 0) { + setSelectedRole(initialRoles[0]); + } else { + setSelectedRole(null); // No roles need assignment + } + } else if (shift && role) { + setSelectedRole({ shift, role }); + } + } + }, [open, event, shift, role]); + + const { data: allStaff = [] } = useQuery({ + queryKey: ['staff-for-assignment'], + queryFn: () => base44.entities.Staff.list(), + enabled: open, + }); + + const { data: allEvents = [] } = useQuery({ + queryKey: ['events-for-conflict-check'], + queryFn: () => base44.entities.Event.list(), + enabled: open, + }); + + const { data: vendorRates = [] } = useQuery({ + queryKey: ['vendor-rates-assignment'], + queryFn: () => base44.entities.VendorRate.list(), + enabled: open, + initialData: [], + }); + + // Get all roles that need assignment for display in the header + const allRoles = useMemo(() => { + if (!event) return []; + const roles = []; + (event.shifts || []).forEach(s => { + (s.roles || []).forEach(r => { + const currentAssignedCount = event.assigned_staff?.filter(staff => + staff.role === r.role && staff.shift_name === s.shift_name + )?.length || 0; + const remaining = Math.max((r.count || 0) - currentAssignedCount, 0); + if (remaining > 0) { + roles.push({ + shift: s, + role: r, + currentAssigned: currentAssignedCount, + remaining, + label: `${r.role} (${remaining} needed)` + }); + } + }); + }); + return roles; + }, [event]); + + // Use selectedRole for current assignment context + const currentRole = selectedRole?.role; + const currentShift = selectedRole?.shift; + + const requiredCount = currentRole?.count || 1; + const currentAssigned = event?.assigned_staff?.filter(s => + s.role === currentRole?.role && s.shift_name === currentShift?.shift_name + )?.length || 0; + const remainingCount = Math.max(requiredCount - currentAssigned, 0); + + const eligibleStaff = useMemo(() => { + if (!currentRole || !event) return []; + + return allStaff + .filter(staff => { + // Check if position matches + const positionMatch = staff.position === currentRole.role || + staff.position_2 === currentRole.role || + staff.position?.toLowerCase() === currentRole.role?.toLowerCase() || + staff.position_2?.toLowerCase() === currentRole.role?.toLowerCase(); + + if (!positionMatch) return false; + + if (searchQuery) { + const query = searchQuery.toLowerCase(); + const nameMatch = staff.employee_name?.toLowerCase().includes(query); + const locationMatch = staff.hub_location?.toLowerCase().includes(query); + if (!nameMatch && !locationMatch) return false; + } + + return true; + }) + .map(staff => { + // Check for time conflicts + const conflicts = allEvents.filter(e => { + if (e.id === event.id) return false; // Don't conflict with current event + if (e.status === "Canceled" || e.status === "Completed") return false; // Ignore past/canceled events + + const isAssignedToEvent = e.assigned_staff?.some(s => s.staff_id === staff.id); + if (!isAssignedToEvent) return false; // Staff not assigned to this event + + // Check for time overlap within the conflicting event's shifts + const eventShifts = e.shifts || []; + return eventShifts.some(eventShift => { + const eventRoles = eventShift.roles || []; + return eventRoles.some(eventRole => { + // Ensure staff is assigned to this specific role within the conflicting shift + const isStaffAssignedToThisRole = e.assigned_staff?.some( + s => s.staff_id === staff.id && s.role === eventRole.role && s.shift_name === eventShift.shift_name + ); + if (!isStaffAssignedToThisRole) return false; + + const shiftStart = `${e.date}T${eventRole.start_time || '00:00'}`; + const shiftEnd = `${e.date}T${eventRole.end_time || '23:59'}`; + const currentStart = `${event.date}T${currentRole.start_time || '00:00'}`; + const currentEnd = `${event.date}T${currentRole.end_time || '23:59'}`; + + return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd); + }); + }); + }); + + const hasConflict = conflicts.length > 0; + const totalShifts = staff.total_shifts || 0; + const reliability = staff.reliability_score || (totalShifts > 0 ? 85 : 0); + + // Calculate smart scores + // Skill score is implicitly 100 if they pass the filter (position match) + const fatigueScore = 100 - Math.min((totalShifts / 30) * 100, 100); // More shifts = more fatigue = lower score + const complianceScore = staff.background_check_status === 'cleared' ? 100 : 50; // Simple compliance check + const proximityScore = staff.hub_location === event.hub ? 100 : 50; // Location match + const costRate = vendorRates.find(r => r.vendor_id === staff.vendor_id && r.role_name === currentRole.role); + const costScore = costRate ? Math.max(0, 100 - (costRate.client_rate / 50) * 100) : 50; // Lower rate = higher score + + const smartScore = ( + (priorities.skill / 100) * 100 + // Skill is 100 if eligible + (priorities.reliability / 100) * reliability + + (priorities.fatigue / 100) * fatigueScore + + (priorities.compliance / 100) * complianceScore + + (priorities.proximity / 100) * proximityScore + + (priorities.cost / 100) * costScore + ) / 6; // Divided by number of priorities (6) + + return { + ...staff, + hasConflict, + conflictDetails: conflicts, + reliability, + shiftCount: totalShifts, + smartScore, + scores: { + fatigue: fatigueScore, + compliance: complianceScore, + proximity: proximityScore, + cost: costScore, + } + }; + }) + .sort((a, b) => { + if (sortMode === "smart") { + // Prioritize non-conflicting staff first, then by smart score + if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1; + return b.smartScore - a.smartScore; + } else { + // Manual mode: Prioritize non-conflicting, then reliability, then shift count + if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1; + if (b.reliability !== a.reliability) return b.reliability - a.reliability; + return (b.shiftCount || 0) - (a.shiftCount || 0); + } + }); + }, [allStaff, allEvents, currentRole, event, currentShift, searchQuery, sortMode, priorities, vendorRates]); + + const availableStaff = eligibleStaff.filter(s => !s.hasConflict); + const unavailableStaff = eligibleStaff.filter(s => s.hasConflict); + + const handleSelectBest = () => { + const best = availableStaff.slice(0, remainingCount); + const newSelected = new Set(best.map(s => s.id)); + setSelected(newSelected); + }; + + const toggleSelect = (staffId) => { + const newSelected = new Set(selected); + if (newSelected.has(staffId)) { + newSelected.delete(staffId); + } else { + if (newSelected.size >= remainingCount) { + toast({ + title: "Limit Reached", + description: `You can only assign ${remainingCount} more ${currentRole.role}${remainingCount > 1 ? 's' : ''} to this role.`, + variant: "destructive", + }); + return; + } + newSelected.add(staffId); + } + setSelected(newSelected); + }; + + const assignMutation = useMutation({ + mutationFn: async () => { + const selectedStaff = eligibleStaff.filter(s => selected.has(s.id)); + + // Send notifications to unavailable staff who are being assigned + const unavailableSelected = selectedStaff.filter(s => s.hasConflict); + for (const staff of unavailableSelected) { + try { + // This is a placeholder for sending an actual email/notification + // In a real application, you'd use a robust notification service. + await base44.integrations.Core.SendEmail({ // Assuming base44.integrations.Core exists and has SendEmail + to: staff.email || `${staff.employee_name.replace(/\s/g, '').toLowerCase()}@example.com`, + subject: `New Shift Assignment - ${event.event_name} (Possible Conflict)`, + body: `Dear ${staff.employee_name},\n\nYou have been assigned to work as a ${currentRole.role} for the event "${event.event_name}" on ${format(new Date(event.date), 'MMM d, yyyy')} from ${currentRole.start_time} to ${currentRole.end_time} at ${event.hub || event.event_location}.\n\nOur records indicate this assignment might overlap with another scheduled shift. Please review your schedule and confirm your availability for this new assignment as soon as possible.\n\nThank you!` + }); + } catch (error) { + console.error("Failed to send email to conflicted staff:", staff.employee_name, error); + // Decide whether to block assignment or just log the error + } + } + + const updatedAssignedStaff = [ + ...(event.assigned_staff || []), + ...selectedStaff.map(s => ({ + staff_id: s.id, + staff_name: s.employee_name, + email: s.email, + role: currentRole.role, + department: currentRole.department, + shift_name: currentShift.shift_name, // Include shift_name + })) + ]; + + const updatedShifts = (event.shifts || []).map(s => { + if (s.shift_name === currentShift.shift_name) { + const updatedRoles = (s.roles || []).map(r => { + if (r.role === currentRole.role) { + return { + ...r, + assigned: (r.assigned || 0) + selected.size, + }; + } + return r; + }); + return { ...s, roles: updatedRoles }; + } + return s; + }); + + await base44.entities.Event.update(event.id, { + assigned_staff: updatedAssignedStaff, + shifts: updatedShifts, + requested: (event.requested || 0) + selected.size, // This `requested` field might need more careful handling if it's meant to be total + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + queryClient.invalidateQueries({ queryKey: ['all-events-vendor'] }); // New query key + queryClient.invalidateQueries({ queryKey: ['vendor-events'] }); // New query key + toast({ + title: "✅ Staff Assigned", + description: `Successfully assigned ${selected.size} ${currentRole.role}${selected.size > 1 ? 's' : ''}`, + }); + setSelected(new Set()); // Clear selection after assignment + + // Auto-select the next role that needs assignment + const currentRoleIdentifier = { role: currentRole.role, shift_name: currentShift.shift_name }; + const currentIndex = allRoles.findIndex(ar => ar.role.role === currentRoleIdentifier.role && ar.shift.shift_name === currentRoleIdentifier.shift_name); + + if (currentIndex !== -1 && currentIndex + 1 < allRoles.length) { + setSelectedRole(allRoles[currentIndex + 1]); + } else { + onClose(); // Close if no more roles to assign + } + }, + onError: (error) => { + toast({ + title: "❌ Assignment Failed", + description: error.message, + variant: "destructive", + }); + }, + }); + + const handleAssign = () => { + if (selected.size === 0) { + toast({ + title: "No Selection", + description: "Please select at least one staff member", + variant: "destructive", + }); + return; + } + + // The logic to check for conflicts and stop was removed because + // the new assignMutation now sends notifications to conflicted staff. + // If a hard stop for conflicts is desired, this check should be re-enabled + // and the notification logic in assignMutation modified. + + assignMutation.mutate(); + }; + + if (!event) return null; + // If there's no currentRole, it means either props were not passed or all roles are already assigned + if (!currentRole || !currentShift) { + return ( + + + + No Roles to Assign + +

All positions for this order are fully staffed, or no roles were specified.

+ + + +
+
+ ); + } + + const statusColor = remainingCount === 0 + ? "bg-green-100 text-green-700 border-green-300" + : currentAssigned > 0 + ? "bg-blue-100 text-blue-700 border-blue-300" + : "bg-slate-100 text-slate-700 border-slate-300"; + + return ( + + + +
+
+ + + Smart Assign Staff + +
+ + + {event.event_name} + + + {event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'} +
+
+
+ + {selected.size} / {remainingCount} Selected + +
+
+ + {/* Role Selector */} + {allRoles.length > 1 && ( +
+ {allRoles.map((roleItem, idx) => ( + + ))} +
+ )} +
+ + + + + + Smart Assignment + + + + Manual Selection + + + + + {/* Priority Controls */} +
+
+ +

Assignment Priorities

+
+
+
+
+ + + Reliability + + {priorities.reliability}% +
+ setPriorities({...priorities, reliability: v[0]})} + max={100} + step={10} + className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" + /> +
+
+
+ + + Fatigue + + {priorities.fatigue}% +
+ setPriorities({...priorities, fatigue: v[0]})} + max={100} + step={10} + className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" + /> +
+
+
+ + + Compliance + + {priorities.compliance}% +
+ setPriorities({...priorities, compliance: v[0]})} + max={100} + step={10} + className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" + /> +
+
+
+ + + Proximity + + {priorities.proximity}% +
+ setPriorities({...priorities, proximity: v[0]})} + max={100} + step={10} + className="[&_[role=slider]]:bg-[#0A39DF] [&_[role=track]]:bg-slate-200" + /> +
+
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]" + /> +
+ +
+
+
+ + {availableStaff.length} Available +
+ {unavailableStaff.length > 0 && ( +
+ + {unavailableStaff.length} Unavailable +
+ )} +
+ + +
+ +
+ {eligibleStaff.length === 0 ? ( +
+ +

No {currentRole.role}s found

+

Try adjusting your search or check staff positions

+
+ ) : ( +
+ {/* Available Staff First */} + {availableStaff.length > 0 && ( + <> +
+

Available ({availableStaff.length})

+
+ {availableStaff.map((staff) => { + const isSelected = selected.has(staff.id); + + return ( +
toggleSelect(staff.id)} + > + toggleSelect(staff.id)} + className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" + onClick={(e) => e.stopPropagation()} + /> + + + {staff.employee_name} + + +
+
+

{staff.employee_name}

+ + {Math.round(staff.smartScore)}% Match + +
+
+ + + {staff.reliability}% + + + + {Math.round(staff.scores.fatigue)} + + + + {Math.round(staff.scores.compliance)} + + {staff.hub_location && ( + + + {staff.hub_location} + + )} +
+
+ +
+ + {staff.shiftCount || 0} shifts + + + Available + +
+
+ ); + })} + + )} + + {/* Unavailable Staff */} + {unavailableStaff.length > 0 && ( + <> +
+
+

Unavailable ({unavailableStaff.length})

+ + Will be notified if assigned +
+
+ {unavailableStaff.map((staff) => { + const isSelected = selected.has(staff.id); + + return ( +
toggleSelect(staff.id)} + > + toggleSelect(staff.id)} + className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" + onClick={(e) => e.stopPropagation()} + /> + + + {staff.employee_name} + + +
+
+

{staff.employee_name}

+ + {Math.round(staff.smartScore)}% Match + +
+
+ + + Time Conflict + + {staff.hub_location && ( + + + {staff.hub_location} + + )} +
+
+ +
+ + {staff.shiftCount || 0} shifts + + + Will Notify + +
+
+ ); + })} + + )} +
+ )} +
+
+
+ + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 border-2 border-slate-200 focus:border-[#0A39DF]" + /> +
+ +
+
+
+ + {availableStaff.length} Available {currentRole.role}s +
+ {unavailableStaff.length > 0 && ( +
+ + {unavailableStaff.length} Conflicts +
+ )} +
+ + +
+ +
+ {eligibleStaff.length === 0 ? ( +
+ +

No {currentRole.role}s found

+

Try adjusting your search or filters

+
+ ) : ( +
+ {eligibleStaff.map((staff) => { + const isSelected = selected.has(staff.id); + // In manual mode, we still allow selection of conflicted staff, + // and the system will notify them. + + return ( +
toggleSelect(staff.id)} + > + toggleSelect(staff.id)} + className="w-5 h-5 rounded border-2 border-slate-300 text-[#0A39DF] focus:ring-[#0A39DF]" + onClick={(e) => e.stopPropagation()} + /> + + + {staff.employee_name} + + +
+
+

{staff.employee_name}

+ {staff.rating && ( +
+ + {staff.rating.toFixed(1)} +
+ )} +
+
+ + {currentRole.role} + + {staff.hub_location && ( + + + {staff.hub_location} + + )} +
+
+ +
+ + {staff.shiftCount || 0} shifts + + {staff.hasConflict ? ( + + Conflict (Will Notify) + + ) : ( + + Available + + )} +
+
+ ); + })} +
+ )} +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend-web/src/components/events/VendorRoutingPanel.jsx b/frontend-web/src/components/events/VendorRoutingPanel.jsx new file mode 100644 index 00000000..b45ac725 --- /dev/null +++ b/frontend-web/src/components/events/VendorRoutingPanel.jsx @@ -0,0 +1,340 @@ +import React, { useState } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react"; + +export default function VendorRoutingPanel({ + user, + selectedVendors = [], + onVendorChange, + isRapid = false +}) { + const [showVendorSelector, setShowVendorSelector] = useState(false); + const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi' + + // Fetch preferred vendor + const { data: preferredVendor } = useQuery({ + queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id], + queryFn: async () => { + if (!user?.preferred_vendor_id) return null; + const vendors = await base44.entities.Vendor.list(); + return vendors.find(v => v.id === user.preferred_vendor_id); + }, + enabled: !!user?.preferred_vendor_id, + }); + + // Fetch all approved vendors + const { data: allVendors } = useQuery({ + queryKey: ['all-vendors-routing'], + queryFn: () => base44.entities.Vendor.filter({ + approval_status: 'approved', + is_active: true + }), + initialData: [], + }); + + // Auto-select preferred vendor on mount if none selected + React.useEffect(() => { + if (preferredVendor && selectedVendors.length === 0) { + onVendorChange([preferredVendor]); + } + }, [preferredVendor]); + + const handleVendorSelect = (vendor) => { + if (selectionMode === 'single') { + onVendorChange([vendor]); + setShowVendorSelector(false); + } else { + // Multi-select mode + const isSelected = selectedVendors.some(v => v.id === vendor.id); + if (isSelected) { + onVendorChange(selectedVendors.filter(v => v.id !== vendor.id)); + } else { + onVendorChange([...selectedVendors, vendor]); + } + } + }; + + const handleMultiVendorDone = () => { + if (selectedVendors.length === 0) { + alert("Please select at least one vendor"); + return; + } + setShowVendorSelector(false); + }; + + const routingMode = selectedVendors.length > 1 ? 'multi' : 'single'; + + return ( + <> + + +
+ {/* Header */} +
+
+
+ {isRapid ? ( + + ) : ( + + )} +
+
+

+ {isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'} +

+

+ {routingMode === 'multi' + ? `Sending to ${selectedVendors.length} vendors` + : 'Default vendor routing'} +

+
+
+ + {routingMode === 'multi' && ( + + MULTI-VENDOR + + )} +
+ + {/* Selected Vendor(s) */} +
+ {selectedVendors.length === 0 && !preferredVendor && ( +
+
+ +

+ No vendor selected. Please choose a vendor. +

+
+
+ )} + + {selectedVendors.map((vendor) => { + const isPreferred = vendor.id === preferredVendor?.id; + + return ( +
+
+
+
+

+ {vendor.doing_business_as || vendor.legal_name} +

+ {isPreferred && ( + + + Preferred + + )} +
+
+ {vendor.region && ( + + + {vendor.region} + + )} + + + {vendor.workforce_count || 0} staff + +
+
+ {routingMode === 'multi' && ( + + )} +
+
+ ); + })} +
+ + {/* Action Buttons */} +
+ + +
+ + {/* Info Banner */} + {routingMode === 'multi' && ( +
+

+ Multi-Vendor Mode: Order sent to all selected vendors. + First to confirm gets the job. +

+
+ )} + + {isRapid && ( +
+

+ RAPID Priority: This order will be marked urgent with priority notification. +

+
+ )} +
+
+
+ + {/* Vendor Selector Dialog */} + + + + + {selectionMode === 'multi' ? ( + <> + + Select Multiple Vendors + + ) : ( + <> + + Select Vendor + + )} + + + {selectionMode === 'multi' + ? 'Select multiple vendors to send this order to all at once. First to confirm gets the job.' + : 'Choose which vendor should receive this order'} + + + +
+ {allVendors.map((vendor) => { + const isSelected = selectedVendors.some(v => v.id === vendor.id); + const isPreferred = vendor.id === preferredVendor?.id; + + return ( +
handleVendorSelect(vendor)} + > +
+
+
+

+ {vendor.doing_business_as || vendor.legal_name} +

+ {isPreferred && ( + + + Preferred + + )} + {isSelected && selectionMode === 'multi' && ( + + + Selected + + )} +
+
+ {vendor.region && ( + + + {vendor.region} + + )} + {vendor.service_specialty && ( + + {vendor.service_specialty} + + )} +
+
+ + + {vendor.workforce_count || 0} staff + + + + 4.9 + + + + 98% fill rate + +
+
+
+
+ ); + })} + + {allVendors.length === 0 && ( +
+ +

No vendors available

+
+ )} +
+ + {selectionMode === 'multi' && ( +
+

+ {selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected +

+ +
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/frontend-web/src/components/notifications/NotificationEngine.jsx b/frontend-web/src/components/notifications/NotificationEngine.jsx new file mode 100644 index 00000000..42779907 --- /dev/null +++ b/frontend-web/src/components/notifications/NotificationEngine.jsx @@ -0,0 +1,247 @@ +import React, { useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +/** + * Automated Notification Engine + * Monitors events and triggers notifications based on configured preferences + */ + +export function NotificationEngine() { + const queryClient = useQueryClient(); + + const { data: events = [] } = useQuery({ + queryKey: ['events-notifications'], + queryFn: () => base44.entities.Event.list(), + refetchInterval: 60000, // Check every minute + }); + + const { data: users = [] } = useQuery({ + queryKey: ['users-notifications'], + queryFn: () => base44.entities.User.list(), + refetchInterval: 300000, // Check every 5 minutes + }); + + const createNotification = async (userId, title, description, activityType, relatedId = null) => { + try { + await base44.entities.ActivityLog.create({ + title, + description, + activity_type: activityType, + user_id: userId, + is_read: false, + related_entity_id: relatedId, + icon_type: activityType.includes('event') ? 'calendar' : activityType.includes('invoice') ? 'invoice' : 'user', + icon_color: 'blue', + }); + } catch (error) { + console.error('Failed to create notification:', error); + } + }; + + const sendEmail = async (to, subject, body, userPreferences) => { + if (!userPreferences?.email_notifications) return; + + try { + await base44.integrations.Core.SendEmail({ + to, + subject, + body, + }); + } catch (error) { + console.error('Failed to send email:', error); + } + }; + + // Shift assignment notifications + useEffect(() => { + const notifyStaffAssignments = async () => { + for (const event of events) { + if (!event.assigned_staff || event.assigned_staff.length === 0) continue; + + for (const staff of event.assigned_staff) { + const user = users.find(u => u.email === staff.email); + if (!user) continue; + + const prefs = user.notification_preferences || {}; + if (!prefs.shift_assignments) continue; + + // Check if notification already sent (within last 24h) + const recentNotifs = await base44.entities.ActivityLog.filter({ + user_id: user.id, + activity_type: 'staff_assigned', + related_entity_id: event.id, + }); + + const alreadyNotified = recentNotifs.some(n => { + const notifDate = new Date(n.created_date); + const hoursSince = (Date.now() - notifDate.getTime()) / (1000 * 60 * 60); + return hoursSince < 24; + }); + + if (alreadyNotified) continue; + + await createNotification( + user.id, + '🎯 New Shift Assignment', + `You've been assigned to ${event.event_name} on ${new Date(event.date).toLocaleDateString()}`, + 'staff_assigned', + event.id + ); + + await sendEmail( + staff.email, + `New Shift Assignment - ${event.event_name}`, + `Hello ${staff.staff_name},\n\nYou've been assigned to work at ${event.event_name}.\n\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nPlease confirm your availability in the KROW platform.\n\nThank you!`, + prefs + ); + } + } + }; + + if (events.length > 0 && users.length > 0) { + notifyStaffAssignments(); + } + }, [events, users]); + + // Shift reminder (24 hours before) + useEffect(() => { + const sendShiftReminders = async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const tomorrowEnd = new Date(tomorrow); + tomorrowEnd.setHours(23, 59, 59, 999); + + for (const event of events) { + const eventDate = new Date(event.date); + if (eventDate < tomorrow || eventDate > tomorrowEnd) continue; + if (!event.assigned_staff || event.assigned_staff.length === 0) continue; + + for (const staff of event.assigned_staff) { + const user = users.find(u => u.email === staff.email); + if (!user) continue; + + const prefs = user.notification_preferences || {}; + if (!prefs.shift_reminders) continue; + + await createNotification( + user.id, + '⏰ Shift Reminder', + `Reminder: Your shift at ${event.event_name} is tomorrow`, + 'event_updated', + event.id + ); + + await sendEmail( + staff.email, + `Shift Reminder - Tomorrow at ${event.event_name}`, + `Hello ${staff.staff_name},\n\nThis is a reminder that you have a shift tomorrow:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\n\nSee you there!`, + prefs + ); + } + } + }; + + if (events.length > 0 && users.length > 0) { + sendShiftReminders(); + } + }, [events, users]); + + // Client upcoming event notifications (3 days before) + useEffect(() => { + const notifyClientsUpcomingEvents = async () => { + const threeDaysFromNow = new Date(); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); + threeDaysFromNow.setHours(0, 0, 0, 0); + + const threeDaysEnd = new Date(threeDaysFromNow); + threeDaysEnd.setHours(23, 59, 59, 999); + + for (const event of events) { + const eventDate = new Date(event.date); + if (eventDate < threeDaysFromNow || eventDate > threeDaysEnd) continue; + + const clientUser = users.find(u => + u.email === event.client_email || + (u.role === 'client' && u.full_name === event.client_name) + ); + + if (!clientUser) continue; + + const prefs = clientUser.notification_preferences || {}; + if (!prefs.upcoming_events) continue; + + await createNotification( + clientUser.id, + '📅 Upcoming Event', + `Your event "${event.event_name}" is in 3 days`, + 'event_created', + event.id + ); + + await sendEmail( + clientUser.email, + `Upcoming Event Reminder - ${event.event_name}`, + `Hello,\n\nThis is a reminder that your event is coming up in 3 days:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Assigned: ${event.assigned_staff?.length || 0}/${event.requested || 0}\n\nIf you need to make any changes, please log into your KROW account.`, + prefs + ); + } + }; + + if (events.length > 0 && users.length > 0) { + notifyClientsUpcomingEvents(); + } + }, [events, users]); + + // Vendor new lead notifications (new events without vendor assignment) + useEffect(() => { + const notifyVendorsNewLeads = async () => { + const newEvents = events.filter(e => + e.status === 'Draft' || e.status === 'Pending' + ); + + const vendorUsers = users.filter(u => u.role === 'vendor'); + + for (const event of newEvents) { + for (const vendor of vendorUsers) { + const prefs = vendor.notification_preferences || {}; + if (!prefs.new_leads) continue; + + // Check if already notified + const recentNotifs = await base44.entities.ActivityLog.filter({ + user_id: vendor.id, + activity_type: 'event_created', + related_entity_id: event.id, + }); + + if (recentNotifs.length > 0) continue; + + await createNotification( + vendor.id, + '🎯 New Lead Available', + `New opportunity: ${event.event_name} needs ${event.requested || 0} staff`, + 'event_created', + event.id + ); + + await sendEmail( + vendor.email, + `New Staffing Opportunity - ${event.event_name}`, + `Hello,\n\nA new staffing opportunity is available:\n\nEvent: ${event.event_name}\nDate: ${new Date(event.date).toLocaleDateString()}\nLocation: ${event.event_location || event.hub}\nStaff Needed: ${event.requested || 0}\n\nLog in to KROW to submit your proposal.`, + prefs + ); + } + } + }; + + if (events.length > 0 && users.length > 0) { + notifyVendorsNewLeads(); + } + }, [events, users]); + + return null; // Background service +} + +export default NotificationEngine; \ No newline at end of file diff --git a/frontend-web/src/components/onboarding/CompletionStep.jsx b/frontend-web/src/components/onboarding/CompletionStep.jsx new file mode 100644 index 00000000..84d302c8 --- /dev/null +++ b/frontend-web/src/components/onboarding/CompletionStep.jsx @@ -0,0 +1,141 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) { + const { profile, documents, training } = data; + + return ( +
+
+
+ +
+

You're All Set! 🎉

+

Review your information before completing onboarding

+
+ + {/* Summary Cards */} +
+ {/* Profile Summary */} + + +
+
+ +
+
+

Profile Information

+
+
+

Name

+

{profile.full_name}

+
+
+

Email

+

{profile.email}

+
+
+

Position

+

{profile.position}

+
+
+

Location

+

{profile.city}

+
+
+
+ +
+
+
+ + {/* Documents Summary */} + + +
+
+ +
+
+

Documents Uploaded

+
+ {documents.map((doc, idx) => ( + + {doc.name} + + ))} + {documents.length === 0 && ( +

No documents uploaded

+ )} +
+
+ +
+
+
+ + {/* Training Summary */} + + +
+
+ +
+
+

Training Completed

+

+ {training.completed.length} training modules completed +

+ {training.acknowledged && ( + Compliance Acknowledged + )} +
+ +
+
+
+
+ + {/* Next Steps */} + + +

What Happens Next?

+
    +
  • + + Your profile will be activated and available for shift assignments +
  • +
  • + + You'll receive an email confirmation with your login credentials +
  • +
  • + + Our team will review your documents within 24-48 hours +
  • +
  • + + You can start accepting shift invitations immediately +
  • +
+
+
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/onboarding/DocumentUploadStep.jsx b/frontend-web/src/components/onboarding/DocumentUploadStep.jsx new file mode 100644 index 00000000..9225f229 --- /dev/null +++ b/frontend-web/src/components/onboarding/DocumentUploadStep.jsx @@ -0,0 +1,159 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Upload, FileText, CheckCircle, X } from "lucide-react"; +import { base44 } from "@/api/base44Client"; +import { useToast } from "@/components/ui/use-toast"; + +const requiredDocuments = [ + { id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' }, + { id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' }, + { id: 'background_check', name: 'Background Check', required: false, description: 'If available' }, +]; + +export default function DocumentUploadStep({ data, onNext, onBack }) { + const [documents, setDocuments] = useState(data || []); + const [uploading, setUploading] = useState({}); + const { toast } = useToast(); + + const handleFileUpload = async (docType, file) => { + if (!file) return; + + setUploading(prev => ({ ...prev, [docType]: true })); + + try { + const { file_url } = await base44.integrations.Core.UploadFile({ file }); + + const newDoc = { + type: docType, + name: file.name, + url: file_url, + uploaded_at: new Date().toISOString(), + }; + + setDocuments(prev => { + const filtered = prev.filter(d => d.type !== docType); + return [...filtered, newDoc]; + }); + + toast({ + title: "✅ Document Uploaded", + description: `${file.name} uploaded successfully`, + }); + } catch (error) { + toast({ + title: "❌ Upload Failed", + description: error.message, + variant: "destructive", + }); + } finally { + setUploading(prev => ({ ...prev, [docType]: false })); + } + }; + + const handleRemoveDocument = (docType) => { + setDocuments(prev => prev.filter(d => d.type !== docType)); + }; + + const handleNext = () => { + const hasRequiredDocs = requiredDocuments + .filter(doc => doc.required) + .every(doc => documents.some(d => d.type === doc.id)); + + if (!hasRequiredDocs) { + toast({ + title: "⚠️ Missing Required Documents", + description: "Please upload all required documents before continuing", + variant: "destructive", + }); + return; + } + + onNext({ type: 'documents', data: documents }); + }; + + const getUploadedDoc = (docType) => documents.find(d => d.type === docType); + + return ( +
+
+

Document Upload

+

Upload required documents for compliance

+
+ +
+ {requiredDocuments.map(doc => { + const uploadedDoc = getUploadedDoc(doc.id); + const isUploading = uploading[doc.id]; + + return ( + + +
+
+
+ + {uploadedDoc && ( + + )} +
+

{doc.description}

+ + {uploadedDoc && ( +
+ + {uploadedDoc.name} + +
+ )} +
+ +
+ + handleFileUpload(doc.id, e.target.files[0])} + disabled={isUploading} + /> +
+
+
+
+ ); + })} +
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/onboarding/ProfileSetupStep.jsx b/frontend-web/src/components/onboarding/ProfileSetupStep.jsx new file mode 100644 index 00000000..320e8e98 --- /dev/null +++ b/frontend-web/src/components/onboarding/ProfileSetupStep.jsx @@ -0,0 +1,193 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { User, Briefcase, MapPin } from "lucide-react"; + +export default function ProfileSetupStep({ data, onNext, currentUser }) { + const [profile, setProfile] = useState({ + full_name: data.full_name || currentUser?.full_name || "", + email: data.email || currentUser?.email || "", + phone: data.phone || "", + address: data.address || "", + city: data.city || "", + position: data.position || "", + department: data.department || "", + hub_location: data.hub_location || "", + employment_type: data.employment_type || "Full Time", + english_level: data.english_level || "Fluent", + }); + + const handleSubmit = (e) => { + e.preventDefault(); + onNext({ type: 'profile', data: profile }); + }; + + const handleChange = (field, value) => { + setProfile(prev => ({ ...prev, [field]: value })); + }; + + return ( +
+
+

Profile Setup

+

Tell us about yourself

+
+ +
+ {/* Personal Information */} +
+ + Personal Information +
+ +
+
+ + handleChange('full_name', e.target.value)} + required + placeholder="John Doe" + /> +
+ +
+ + handleChange('email', e.target.value)} + required + placeholder="john@example.com" + /> +
+ +
+ + handleChange('phone', e.target.value)} + required + placeholder="(555) 123-4567" + /> +
+ +
+ + handleChange('city', e.target.value)} + required + placeholder="San Francisco" + /> +
+ +
+ + handleChange('address', e.target.value)} + placeholder="123 Main St" + /> +
+
+ + {/* Employment Details */} +
+ + Employment Details +
+ +
+
+ + handleChange('position', e.target.value)} + required + placeholder="e.g., Server, Chef, Bartender" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Location */} +
+ + Work Location +
+ +
+ + handleChange('hub_location', e.target.value)} + placeholder="e.g., Downtown SF, Bay Area" + /> +
+
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/onboarding/TrainingStep.jsx b/frontend-web/src/components/onboarding/TrainingStep.jsx new file mode 100644 index 00000000..f525174e --- /dev/null +++ b/frontend-web/src/components/onboarding/TrainingStep.jsx @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { CheckCircle, Circle, Play, BookOpen } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; + +const trainingModules = [ + { + id: 'safety', + title: 'Workplace Safety', + duration: '15 min', + required: true, + description: 'Learn about workplace safety protocols and emergency procedures', + topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'], + }, + { + id: 'hygiene', + title: 'Food Safety & Hygiene', + duration: '20 min', + required: true, + description: 'Essential food handling and hygiene standards', + topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'], + }, + { + id: 'customer_service', + title: 'Customer Service Excellence', + duration: '10 min', + required: true, + description: 'Delivering outstanding service to clients and guests', + topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'], + }, + { + id: 'compliance', + title: 'Compliance & Policies', + duration: '12 min', + required: true, + description: 'Company policies and legal compliance requirements', + topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'], + }, +]; + +export default function TrainingStep({ data, onNext, onBack }) { + const [training, setTraining] = useState(data || { completed: [], acknowledged: false }); + + const handleModuleComplete = (moduleId) => { + setTraining(prev => ({ + ...prev, + completed: prev.completed.includes(moduleId) + ? prev.completed.filter(id => id !== moduleId) + : [...prev.completed, moduleId], + })); + }; + + const handleAcknowledge = (checked) => { + setTraining(prev => ({ ...prev, acknowledged: checked })); + }; + + const handleNext = () => { + const allRequired = trainingModules + .filter(m => m.required) + .every(m => training.completed.includes(m.id)); + + if (!allRequired || !training.acknowledged) { + return; + } + + onNext({ type: 'training', data: training }); + }; + + const isComplete = (moduleId) => training.completed.includes(moduleId); + const allRequiredComplete = trainingModules + .filter(m => m.required) + .every(m => training.completed.includes(m.id)); + + return ( +
+
+

Compliance Training

+

Complete required training modules to ensure readiness

+
+ +
+ {trainingModules.map(module => ( + + +
+
+ {isComplete(module.id) ? ( + + ) : ( + + )} +
+ +
+
+
+

+ {module.title} + {module.required && *} +

+

{module.duration} · {module.description}

+
+
+ +
    + {module.topics.map((topic, idx) => ( +
  • {topic}
  • + ))} +
+ + +
+
+
+
+ ))} +
+ + {allRequiredComplete && ( + + +
+ + +
+
+
+ )} + +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/orders/OrderStatusBadge.jsx b/frontend-web/src/components/orders/OrderStatusBadge.jsx new file mode 100644 index 00000000..6837a5c5 --- /dev/null +++ b/frontend-web/src/components/orders/OrderStatusBadge.jsx @@ -0,0 +1,149 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react"; + +// Comprehensive color coding system +export const ORDER_STATUSES = { + RAPID: { + color: "bg-red-600 text-white border-0", + dotColor: "bg-red-400", + icon: Zap, + label: "RAPID", + priority: 1, + description: "Must be filled immediately" + }, + REQUESTED: { + color: "bg-yellow-500 text-white border-0", + dotColor: "bg-yellow-300", + icon: Clock, + label: "Requested", + priority: 2, + description: "Pending vendor review" + }, + PARTIALLY_ASSIGNED: { + color: "bg-orange-500 text-white border-0", + dotColor: "bg-orange-300", + icon: AlertTriangle, + label: "Partially Assigned", + priority: 3, + description: "Missing staff" + }, + FULLY_ASSIGNED: { + color: "bg-green-600 text-white border-0", + dotColor: "bg-green-400", + icon: CheckCircle, + label: "Fully Assigned", + priority: 4, + description: "All staff confirmed" + }, + AT_RISK: { + color: "bg-purple-600 text-white border-0", + dotColor: "bg-purple-400", + icon: AlertTriangle, + label: "At Risk", + priority: 2, + description: "Workers not confirmed or declined" + }, + COMPLETED: { + color: "bg-slate-400 text-white border-0", + dotColor: "bg-slate-300", + icon: CheckCircle, + label: "Completed", + priority: 5, + description: "Invoice and approval pending" + }, + PERMANENT: { + color: "bg-purple-700 text-white border-0", + dotColor: "bg-purple-500", + icon: Package, + label: "Permanent", + priority: 3, + description: "Permanent staffing" + }, + CANCELED: { + color: "bg-slate-500 text-white border-0", + dotColor: "bg-slate-300", + icon: XCircle, + label: "Canceled", + priority: 6, + description: "Order canceled" + } +}; + +export function getOrderStatus(order) { + // Check if RAPID + if (order.is_rapid || order.event_name?.includes("RAPID")) { + return ORDER_STATUSES.RAPID; + } + + const assignedCount = order.assigned_staff?.length || 0; + const requestedCount = order.requested || 0; + + // Check completion status + if (order.status === "Completed") { + return ORDER_STATUSES.COMPLETED; + } + + if (order.status === "Canceled") { + return ORDER_STATUSES.CANCELED; + } + + // Check if permanent + if (order.contract_type === "Permanent" || order.event_type === "Permanent") { + return ORDER_STATUSES.PERMANENT; + } + + // Check assignment status + if (requestedCount > 0) { + if (assignedCount >= requestedCount) { + return ORDER_STATUSES.FULLY_ASSIGNED; + } else if (assignedCount > 0) { + return ORDER_STATUSES.PARTIALLY_ASSIGNED; + } else { + return ORDER_STATUSES.REQUESTED; + } + } + + // Default to requested + return ORDER_STATUSES.REQUESTED; +} + +export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) { + const status = getOrderStatus(order); + const Icon = status.icon; + + const sizeClasses = { + sm: "px-2 py-0.5 text-[10px]", + default: "px-3 py-1 text-xs", + lg: "px-4 py-1.5 text-sm" + }; + + return ( + + {showDot && ( +
+ )} + {showIcon && } + {status.label} + + ); +} + +// Helper function to sort orders by priority +export function sortOrdersByPriority(orders) { + return [...orders].sort((a, b) => { + const statusA = getOrderStatus(a); + const statusB = getOrderStatus(b); + + // First by priority + if (statusA.priority !== statusB.priority) { + return statusA.priority - statusB.priority; + } + + // Then by date (most recent first) + return new Date(b.date || b.created_date) - new Date(a.date || a.created_date); + }); +} \ No newline at end of file diff --git a/frontend-web/src/components/orders/RapidOrderChat.jsx b/frontend-web/src/components/orders/RapidOrderChat.jsx new file mode 100644 index 00000000..51719c09 --- /dev/null +++ b/frontend-web/src/components/orders/RapidOrderChat.jsx @@ -0,0 +1,332 @@ +import React, { useState } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { motion, AnimatePresence } from "framer-motion"; + +export default function RapidOrderChat({ onOrderCreated }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [message, setMessage] = useState(""); + const [conversation, setConversation] = useState([]); + const [detectedOrder, setDetectedOrder] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const { data: user } = useQuery({ + queryKey: ['current-user-rapid'], + queryFn: () => base44.auth.me(), + }); + + const { data: businesses } = useQuery({ + queryKey: ['user-businesses'], + queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }), + enabled: !!user, + initialData: [], + }); + + const createRapidOrderMutation = useMutation({ + mutationFn: (orderData) => base44.entities.Event.create(orderData), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + toast({ + title: "✅ RAPID Order Created", + description: "Order sent to preferred vendor with priority notification", + }); + if (onOrderCreated) onOrderCreated(data); + // Reset + setConversation([]); + setDetectedOrder(null); + setMessage(""); + }, + }); + + const analyzeMessage = async (msg) => { + setIsProcessing(true); + + // Add user message to conversation + setConversation(prev => [...prev, { role: 'user', content: msg }]); + + try { + // Use AI to parse the message + const response = await base44.integrations.Core.InvokeLLM({ + prompt: `You are an order assistant. Analyze this message and extract order details: + +Message: "${msg}" +Current user: ${user?.full_name} +User's locations: ${businesses.map(b => b.business_name).join(', ')} + +Extract: +1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now) +2. Role/position needed (cook, bartender, server, dishwasher, etc.) +3. Number of staff (if mentioned) +4. Time frame (if mentioned) +5. Location (if mentioned, otherwise use first available location) + +Return a concise summary.`, + response_json_schema: { + type: "object", + properties: { + is_urgent: { type: "boolean" }, + role: { type: "string" }, + count: { type: "number" }, + location: { type: "string" }, + time_mentioned: { type: "boolean" }, + start_time: { type: "string" }, + end_time: { type: "string" } + } + } + }); + + const parsed = response; + const primaryLocation = businesses[0]?.business_name || "Primary Location"; + + const order = { + is_rapid: parsed.is_urgent || true, + role: parsed.role || "Staff Member", + count: parsed.count || 1, + location: parsed.location || primaryLocation, + start_time: parsed.start_time || "ASAP", + end_time: parsed.end_time || "End of shift", + business_name: primaryLocation, + hub: businesses[0]?.hub_building || "Main Hub" + }; + + setDetectedOrder(order); + + // AI response + const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time} → ${order.end_time}`; + + setConversation(prev => [...prev, { + role: 'assistant', + content: aiMessage, + showConfirm: true + }]); + + } catch (error) { + setConversation(prev => [...prev, { + role: 'assistant', + content: "I couldn't process that. Please provide more details like: role needed, how many, and when." + }]); + } finally { + setIsProcessing(false); + } + }; + + const handleSendMessage = () => { + if (!message.trim()) return; + analyzeMessage(message); + setMessage(""); + }; + + const handleConfirmOrder = () => { + if (!detectedOrder) return; + + const now = new Date(); + const orderData = { + event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`, + is_rapid: true, + status: "Pending", + business_name: detectedOrder.business_name, + hub: detectedOrder.hub, + event_location: detectedOrder.location, + date: now.toISOString().split('T')[0], + requested: detectedOrder.count, + client_name: user?.full_name, + client_email: user?.email, + notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`, + shifts: [{ + shift_name: "Emergency Shift", + roles: [{ + role: detectedOrder.role, + count: detectedOrder.count, + start_time: "ASAP", + end_time: "End of shift" + }] + }] + }; + + createRapidOrderMutation.mutate(orderData); + }; + + const handleEditOrder = () => { + setConversation(prev => [...prev, { + role: 'assistant', + content: "Please describe what you'd like to change." + }]); + setDetectedOrder(null); + }; + + return ( + + +
+
+ +
+
+ + + RAPID Order Assistant + +

Emergency staffing in minutes

+
+ + URGENT + +
+
+ + + {/* Chat Messages */} +
+ {conversation.length === 0 && ( +
+
+ +
+

Need staff urgently?

+

Just describe what you need, I'll handle the rest

+
+
+ Example: "We had a call out. Need 2 cooks ASAP" +
+
+ Example: "Emergency! Need bartender for tonight" +
+
+
+ )} + + + {conversation.map((msg, idx) => ( + +
+ {msg.role === 'assistant' && ( +
+
+ +
+ AI Assistant +
+ )} +

+ {msg.content} +

+ + {msg.showConfirm && detectedOrder && ( +
+
+
+ +
+

Staff Needed

+

{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}

+
+
+
+ +
+

Location

+

{detectedOrder.location}

+
+
+
+ +
+

Time

+

{detectedOrder.start_time} → {detectedOrder.end_time}

+
+
+
+ +
+ + +
+
+ )} +
+
+ ))} +
+ + {isProcessing && ( + +
+
+
+ +
+ Processing your request... +
+
+
+ )} +
+ + {/* Input */} +
+ setMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()} + placeholder="Describe what you need... (e.g., 'Need 2 cooks ASAP')" + className="flex-1 border-2 border-red-300 focus:border-red-500 text-base" + disabled={isProcessing} + /> + +
+ + {/* Helper Text */} +
+
+ +
+ Tip: Include role, quantity, and urgency for fastest processing. + AI will auto-detect your location and send to your preferred vendor. +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/orders/SmartAssignModal.jsx b/frontend-web/src/components/orders/SmartAssignModal.jsx new file mode 100644 index 00000000..bafc8dbd --- /dev/null +++ b/frontend-web/src/components/orders/SmartAssignModal.jsx @@ -0,0 +1,374 @@ +import React, { useState, useMemo } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, 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 { Card, CardContent } from "@/components/ui/card"; +import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selectedWorkers, setSelectedWorkers] = useState([]); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [aiRecommendations, setAiRecommendations] = useState(null); + + const { data: allStaff = [] } = useQuery({ + queryKey: ['staff-smart-assign'], + queryFn: () => base44.entities.Staff.list(), + }); + + const { data: allEvents = [] } = useQuery({ + queryKey: ['events-conflict-check'], + queryFn: () => base44.entities.Event.list(), + }); + + // Smart filtering + const eligibleStaff = useMemo(() => { + if (!event || !roleNeeded) return []; + + return allStaff.filter(worker => { + // Role match + const hasRole = worker.position === roleNeeded || + worker.position_2 === roleNeeded || + worker.profile_type === "Cross-Trained"; + + // Availability check + const isAvailable = worker.employment_type !== "Medical Leave" && + worker.action !== "Inactive"; + + // Conflict check - check if worker is already assigned + const eventDate = new Date(event.date); + const hasConflict = allEvents.some(e => { + if (e.id === event.id) return false; + const eDate = new Date(e.date); + return eDate.toDateString() === eventDate.toDateString() && + e.assigned_staff?.some(s => s.staff_id === worker.id); + }); + + return hasRole && isAvailable && !hasConflict; + }); + }, [allStaff, event, roleNeeded, allEvents]); + + // Run AI analysis + const runSmartAnalysis = async () => { + setIsAnalyzing(true); + + try { + const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job. + +Event: ${event.event_name} +Location: ${event.event_location || event.hub} +Role Needed: ${roleNeeded} +Quantity: ${countNeeded} + +Workers (JSON): +${JSON.stringify(eligibleStaff.map(w => ({ + id: w.id, + name: w.employee_name, + rating: w.rating || 0, + reliability_score: w.reliability_score || 0, + total_shifts: w.total_shifts || 0, + no_show_count: w.no_show_count || 0, + position: w.position, + city: w.city, + profile_type: w.profile_type +})), null, 2)} + +Rank them by: +1. Skills match (exact role match gets priority) +2. Rating (higher is better) +3. Reliability (lower no-shows, higher reliability score) +4. Experience (more shifts completed) +5. Distance (prefer closer to location) + +Return the top ${countNeeded} worker IDs with brief reasoning.`; + + const response = await base44.integrations.Core.InvokeLLM({ + prompt, + response_json_schema: { + type: "object", + properties: { + recommendations: { + type: "array", + items: { + type: "object", + properties: { + worker_id: { type: "string" }, + reason: { type: "string" }, + score: { type: "number" } + } + } + } + } + } + }); + + const recommended = response.recommendations.map(rec => { + const worker = eligibleStaff.find(w => w.id === rec.worker_id); + return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null; + }).filter(Boolean); + + setAiRecommendations(recommended); + setSelectedWorkers(recommended.slice(0, countNeeded)); + + toast({ + title: "✨ AI Analysis Complete", + description: `Found ${recommended.length} optimal matches`, + }); + } catch (error) { + toast({ + title: "Analysis Failed", + description: error.message, + variant: "destructive", + }); + } finally { + setIsAnalyzing(false); + } + }; + + const assignMutation = useMutation({ + mutationFn: async () => { + const assigned_staff = selectedWorkers.map(w => ({ + staff_id: w.id, + staff_name: w.employee_name, + role: roleNeeded + })); + + return base44.entities.Event.update(event.id, { + assigned_staff: [...(event.assigned_staff || []), ...assigned_staff], + status: "Confirmed" + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events'] }); + toast({ + title: "✅ Staff Assigned Successfully", + description: `${selectedWorkers.length} workers assigned to ${event.event_name}`, + }); + onClose(); + }, + }); + + React.useEffect(() => { + if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) { + runSmartAnalysis(); + } + }, [isOpen, eligibleStaff.length]); + + const toggleWorker = (worker) => { + setSelectedWorkers(prev => { + const exists = prev.find(w => w.id === worker.id); + if (exists) { + return prev.filter(w => w.id !== worker.id); + } else if (prev.length < countNeeded) { + return [...prev, worker]; + } + return prev; + }); + }; + + return ( + + + + +
+ +
+
+ Smart Assign (AI Assisted) +

+ AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event +

+
+
+
+ + {isAnalyzing ? ( +
+
+ +
+

Analyzing workforce...

+

AI is finding the optimal matches based on skills, ratings, and availability

+
+ ) : ( + <> + {/* Summary */} +
+ + +
+ +
+

Selected

+

{selectedWorkers.length}/{countNeeded}

+
+
+
+
+ + + +
+ +
+

Avg Rating

+

+ {selectedWorkers.length > 0 + ? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1) + : "—"} +

+
+
+
+
+ + + +
+ +
+

Available

+

{eligibleStaff.length}

+
+
+
+
+
+ + {/* AI Recommendations */} + {aiRecommendations && aiRecommendations.length > 0 ? ( +
+
+

AI Recommendations

+ +
+ + {aiRecommendations.map((worker, idx) => { + const isSelected = selectedWorkers.some(w => w.id === worker.id); + const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected; + + return ( + !isOverLimit && toggleWorker(worker)} + > + +
+
+
+ + + {worker.employee_name?.charAt(0) || 'W'} + + + {idx === 0 && ( +
+ +
+ )} +
+ +
+
+

{worker.employee_name}

+ {idx === 0 && ( + + Top Pick + + )} + + {worker.position} + +
+ +
+
+ + {worker.rating?.toFixed(1) || 'N/A'} +
+
+ + {worker.total_shifts || 0} shifts +
+
+ + {worker.city || 'N/A'} +
+
+ + {worker.ai_reason && ( +
+

+ AI Insight: {worker.ai_reason} +

+
+ )} +
+
+ +
+ {worker.ai_score && ( + + {Math.round(worker.ai_score)}/100 + + )} + {isSelected && ( + + )} +
+
+
+
+ ); + })} +
+ ) : ( +
+ +

No eligible staff found for this role

+
+ )} + + )} + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/orders/WorkerConfirmationCard.jsx b/frontend-web/src/components/orders/WorkerConfirmationCard.jsx new file mode 100644 index 00000000..832f70a6 --- /dev/null +++ b/frontend-web/src/components/orders/WorkerConfirmationCard.jsx @@ -0,0 +1,150 @@ +import React from "react"; +import { base44 } from "@/api/base44Client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { CheckCircle, XCircle, Clock, MapPin, Calendar, AlertTriangle, RefreshCw, Info } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { format } from "date-fns"; + +export default function WorkerConfirmationCard({ assignment, event }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const confirmMutation = useMutation({ + mutationFn: async (status) => { + return base44.entities.Assignment.update(assignment.id, { + assignment_status: status, + confirmed_date: new Date().toISOString() + }); + }, + onSuccess: (_, status) => { + queryClient.invalidateQueries({ queryKey: ['assignments'] }); + toast({ + title: status === "Confirmed" ? "✅ Shift Confirmed" : "❌ Shift Declined", + description: status === "Confirmed" + ? "You're all set! See you at the event." + : "Notified vendor. They'll find a replacement.", + }); + }, + }); + + const getStatusColor = () => { + switch (assignment.assignment_status) { + case "Confirmed": + return "bg-green-100 text-green-700 border-green-300"; + case "Cancelled": + return "bg-red-100 text-red-700 border-red-300"; + case "Pending": + return "bg-yellow-100 text-yellow-700 border-yellow-300"; + default: + return "bg-slate-100 text-slate-700 border-slate-300"; + } + }; + + return ( + + +
+
+
+

{event.event_name}

+ {event.is_rapid && ( + + + RAPID + + )} +
+

{assignment.role}

+
+ + {assignment.assignment_status} + +
+ +
+
+ +
+

Date

+

+ {event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"} +

+
+
+
+ +
+

Time

+

+ {assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"} +

+
+
+
+ +
+

Location

+

{event.event_location || event.hub}

+
+
+
+ + {/* Shift Details */} + {event.shifts?.[0] && ( +
+
+ + Shift Details +
+
+ {event.shifts[0].uniform_type && ( +

Attire: {event.shifts[0].uniform_type}

+ )} + {event.addons?.meal_provided && ( +

Meal: Provided

+ )} + {event.notes && ( +

Notes: {event.notes}

+ )} +
+
+ )} + + {/* Action Buttons */} + {assignment.assignment_status === "Pending" && ( +
+ + +
+ )} + + {assignment.assignment_status === "Confirmed" && ( +
+
+ + You're confirmed for this shift! +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/reports/ClientTrendsReport.jsx b/frontend-web/src/components/reports/ClientTrendsReport.jsx new file mode 100644 index 00000000..b3cadf39 --- /dev/null +++ b/frontend-web/src/components/reports/ClientTrendsReport.jsx @@ -0,0 +1,202 @@ +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 { 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"; + +export default function ClientTrendsReport({ events, invoices }) { + const { toast } = useToast(); + + // Bookings by month + const bookingsByMonth = events.reduce((acc, event) => { + if (!event.date) return acc; + const date = new Date(event.date); + const month = date.toLocaleString('default', { month: 'short' }); + acc[month] = (acc[month] || 0) + 1; + return acc; + }, {}); + + const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({ + month, + bookings: count, + })); + + // Top clients by booking count + const clientBookings = events.reduce((acc, event) => { + const client = event.business_name || 'Unknown'; + if (!acc[client]) { + acc[client] = { name: client, bookings: 0, revenue: 0 }; + } + acc[client].bookings += 1; + acc[client].revenue += event.total || 0; + return acc; + }, {}); + + const topClients = Object.values(clientBookings) + .sort((a, b) => b.bookings - a.bookings) + .slice(0, 10); + + // Client satisfaction (mock data - would come from feedback) + const avgSatisfaction = 4.6; + const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size; + const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1); + + const handleExport = () => { + const csv = [ + ['Client Trends Report'], + ['Generated', new Date().toISOString()], + [''], + ['Summary'], + ['Total Clients', totalClients], + ['Average Satisfaction', avgSatisfaction], + ['Repeat Booking Rate', `${repeatRate}%`], + [''], + ['Top Clients'], + ['Client Name', 'Bookings', 'Revenue'], + ...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]), + [''], + ['Monthly Bookings'], + ['Month', 'Bookings'], + ...monthlyBookings.map(m => [m.month, m.bookings]), + ].map(row => row.join(',')).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" }); + }; + + return ( +
+
+
+

Client Satisfaction & Booking Trends

+

Track client engagement and satisfaction metrics

+
+ +
+ + {/* Summary Cards */} +
+ + +
+
+

Total Clients

+

{totalClients}

+
+
+ +
+
+
+
+ + + +
+
+

Avg Satisfaction

+

{avgSatisfaction}/5

+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+ +
+
+
+
+ + + +
+
+

Repeat Rate

+

{repeatRate}%

+
+
+ +
+
+
+
+
+ + {/* Monthly Booking Trend */} + + + Booking Trend Over Time + + + + + + + + + + + + + + + + {/* Top Clients */} + + + Top Clients by Bookings + + + + + + + + + + + + + + + + {/* Client List */} + + + Client Details + + +
+ {topClients.map((client, idx) => ( +
+
+

{client.name}

+

{client.bookings} bookings

+
+ + ${client.revenue.toLocaleString()} + +
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/reports/CustomReportBuilder.jsx b/frontend-web/src/components/reports/CustomReportBuilder.jsx new file mode 100644 index 00000000..1b5f8a41 --- /dev/null +++ b/frontend-web/src/components/reports/CustomReportBuilder.jsx @@ -0,0 +1,333 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Download, Plus, X } from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/use-toast"; + +export default function CustomReportBuilder({ events, staff, invoices }) { + const { toast } = useToast(); + const [reportConfig, setReportConfig] = useState({ + name: "", + dataSource: "events", + dateRange: "30", + fields: [], + filters: [], + groupBy: "", + }); + + const dataSourceFields = { + events: ['event_name', 'business_name', 'status', 'date', 'total', 'requested', 'hub'], + staff: ['employee_name', 'position', 'department', 'hub_location', 'rating', 'reliability_score'], + invoices: ['invoice_number', 'business_name', 'amount', 'status', 'issue_date', 'due_date'], + }; + + const handleFieldToggle = (field) => { + setReportConfig(prev => ({ + ...prev, + fields: prev.fields.includes(field) + ? prev.fields.filter(f => f !== field) + : [...prev.fields, field], + })); + }; + + const handleGenerateReport = () => { + if (!reportConfig.name || reportConfig.fields.length === 0) { + toast({ + title: "⚠️ Incomplete Configuration", + description: "Please provide a report name and select at least one field.", + variant: "destructive", + }); + return; + } + + // Get data based on source + let data = []; + if (reportConfig.dataSource === 'events') data = events; + else if (reportConfig.dataSource === 'staff') data = staff; + else if (reportConfig.dataSource === 'invoices') data = invoices; + + // Filter data by selected fields + const filteredData = data.map(item => { + const filtered = {}; + reportConfig.fields.forEach(field => { + filtered[field] = item[field] || '-'; + }); + return filtered; + }); + + // Generate CSV + const headers = reportConfig.fields.join(','); + const rows = filteredData.map(item => + reportConfig.fields.map(field => `"${item[field]}"`).join(',') + ); + const csv = [headers, ...rows].join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ + title: "✅ Report Generated", + description: `${reportConfig.name} has been exported successfully.`, + }); + }; + + const handleExportJSON = () => { + if (!reportConfig.name || reportConfig.fields.length === 0) { + toast({ + title: "⚠️ Incomplete Configuration", + description: "Please provide a report name and select at least one field.", + variant: "destructive", + }); + return; + } + + let data = []; + if (reportConfig.dataSource === 'events') data = events; + else if (reportConfig.dataSource === 'staff') data = staff; + else if (reportConfig.dataSource === 'invoices') data = invoices; + + const filteredData = data.map(item => { + const filtered = {}; + reportConfig.fields.forEach(field => { + filtered[field] = item[field] || null; + }); + return filtered; + }); + + const jsonData = { + reportName: reportConfig.name, + generatedAt: new Date().toISOString(), + dataSource: reportConfig.dataSource, + recordCount: filteredData.length, + data: filteredData, + }; + + const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${reportConfig.name.replace(/\s+/g, '-').toLowerCase()}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ + title: "✅ JSON Exported", + description: `${reportConfig.name} exported as JSON.`, + }); + }; + + const availableFields = dataSourceFields[reportConfig.dataSource] || []; + + return ( +
+
+

Custom Report Builder

+

Create custom reports with selected fields and filters

+
+ +
+ {/* Configuration Panel */} + + + Report Configuration + + +
+ + setReportConfig(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Monthly Performance Report" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {availableFields.map(field => ( +
+ handleFieldToggle(field)} + /> + +
+ ))} +
+
+
+
+ + {/* Preview Panel */} + + + Report Preview + + + {reportConfig.name && ( +
+ +

{reportConfig.name}

+
+ )} + +
+ + + {reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)} + +
+ + {reportConfig.fields.length > 0 && ( +
+ +
+ {reportConfig.fields.map(field => ( + + {field.replace(/_/g, ' ')} + + + ))} +
+
+ )} + +
+ + +
+
+
+
+ + {/* Saved Report Templates */} + + + Quick Templates + + +
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/reports/OperationalEfficiencyReport.jsx b/frontend-web/src/components/reports/OperationalEfficiencyReport.jsx new file mode 100644 index 00000000..045b25cb --- /dev/null +++ b/frontend-web/src/components/reports/OperationalEfficiencyReport.jsx @@ -0,0 +1,238 @@ +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 { 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"; + +const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444']; + +export default function OperationalEfficiencyReport({ events, staff }) { + const { toast } = useToast(); + + // Automation impact metrics + const totalEvents = events.length; + const autoAssignedEvents = events.filter(e => + e.assigned_staff && e.assigned_staff.length > 0 + ).length; + const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0; + + // Fill rate by status + const statusBreakdown = events.reduce((acc, event) => { + const status = event.status || 'Draft'; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {}); + + const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({ + name, + value, + })); + + // Time to fill metrics + const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment + const avgResponseTime = 1.5; // Mock - hours to respond to requests + + // Efficiency over time + const efficiencyTrend = [ + { month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 }, + { month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 }, + { month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 }, + { month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 }, + ]; + + const handleExport = () => { + const csv = [ + ['Operational Efficiency Report'], + ['Generated', new Date().toISOString()], + [''], + ['Summary Metrics'], + ['Total Events', totalEvents], + ['Auto-Assigned Events', autoAssignedEvents], + ['Automation Rate', `${automationRate}%`], + ['Avg Time to Fill (hours)', avgTimeToFill], + ['Avg Response Time (hours)', avgResponseTime], + [''], + ['Status Breakdown'], + ['Status', 'Count'], + ...Object.entries(statusBreakdown).map(([status, count]) => [status, count]), + [''], + ['Efficiency Trend'], + ['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'], + ...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]), + ].map(row => row.join(',')).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" }); + }; + + return ( +
+
+
+

Operational Efficiency & Automation Impact

+

Track process improvements and automation effectiveness

+
+ +
+ + {/* Summary Cards */} +
+ + +
+
+

Automation Rate

+

{automationRate}%

+
+
+ +
+
+
+
+ + + +
+
+

Avg Time to Fill

+

{avgTimeToFill}h

+
+
+ +
+
+
+
+ + + +
+
+

Response Time

+

{avgResponseTime}h

+
+
+ +
+
+
+
+ + + +
+
+

Completed

+

{events.filter(e => e.status === 'Completed').length}

+
+
+ +
+
+
+
+
+ + {/* Efficiency Trend */} + + + Efficiency Metrics Over Time + + + + + + + + + + + + + + + + + {/* Status Breakdown */} +
+ + + Event Status Distribution + + + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {statusData.map((entry, index) => ( + + ))} + + + + + + + + + + Key Performance Indicators + + +
+
+

Manual Work Reduction

+

85%

+
+ Excellent +
+
+
+

First-Time Fill Rate

+

92%

+
+ Good +
+
+
+

Staff Utilization

+

88%

+
+ Optimal +
+
+
+

Conflict Detection

+

97%

+
+ High +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/reports/StaffPerformanceReport.jsx b/frontend-web/src/components/reports/StaffPerformanceReport.jsx new file mode 100644 index 00000000..b964ae7f --- /dev/null +++ b/frontend-web/src/components/reports/StaffPerformanceReport.jsx @@ -0,0 +1,226 @@ +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 { 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"; + +export default function StaffPerformanceReport({ staff, events }) { + const { toast } = useToast(); + + // Calculate staff metrics + const staffMetrics = staff.map(s => { + const assignments = events.filter(e => + e.assigned_staff?.some(as => as.staff_id === s.id) + ); + + const completedShifts = assignments.filter(e => e.status === 'Completed').length; + const totalShifts = s.total_shifts || assignments.length || 1; + const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0; + const reliability = s.reliability_score || s.shift_coverage_percentage || 85; + + return { + id: s.id, + name: s.employee_name, + position: s.position, + totalShifts, + completedShifts, + fillRate: parseFloat(fillRate), + reliability, + rating: s.rating || 4.2, + cancellations: s.cancellation_count || 0, + noShows: s.no_show_count || 0, + }; + }).sort((a, b) => b.reliability - a.reliability); + + // Top performers + const topPerformers = staffMetrics.slice(0, 10); + + // Fill rate distribution + const fillRateRanges = [ + { range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length }, + { range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length }, + { range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length }, + { range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length }, + { range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length }, + ]; + + const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0; + const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0; + const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0); + + const handleExport = () => { + const csv = [ + ['Staff Performance Report'], + ['Generated', new Date().toISOString()], + [''], + ['Summary'], + ['Average Reliability', `${avgReliability.toFixed(1)}%`], + ['Average Fill Rate', `${avgFillRate.toFixed(1)}%`], + ['Total Cancellations', totalCancellations], + [''], + ['Staff Details'], + ['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'], + ...staffMetrics.map(s => [ + s.name, + s.position, + s.totalShifts, + s.completedShifts, + `${s.fillRate}%`, + `${s.reliability}%`, + s.rating, + s.cancellations, + s.noShows, + ]), + ].map(row => row.join(',')).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" }); + }; + + return ( +
+
+
+

Staff Performance Metrics

+

Reliability, fill rates, and performance tracking

+
+ +
+ + {/* Summary Cards */} +
+ + +
+
+

Avg Reliability

+

{avgReliability.toFixed(1)}%

+
+
+ +
+
+
+
+ + + +
+
+

Avg Fill Rate

+

{avgFillRate.toFixed(1)}%

+
+
+ +
+
+
+
+ + + +
+
+

Total Cancellations

+

{totalCancellations}

+
+
+ +
+
+
+
+
+ + {/* Fill Rate Distribution */} + + + Fill Rate Distribution + + + + + + + + + + + + + + + + {/* Top Performers Table */} + + + Top Performers + + + + + + Staff Member + Position + Shifts + Fill Rate + Reliability + Rating + + + + {topPerformers.map((staff) => ( + + +
+ + + {staff.name.charAt(0)} + + + {staff.name} +
+
+ {staff.position} + + {staff.completedShifts}/{staff.totalShifts} + + + = 90 ? "bg-green-500" : + staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500" + }> + {staff.fillRate}% + + + + {staff.reliability}% + + + {staff.rating}/5 + +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/reports/StaffingCostReport.jsx b/frontend-web/src/components/reports/StaffingCostReport.jsx new file mode 100644 index 00000000..5c22940b --- /dev/null +++ b/frontend-web/src/components/reports/StaffingCostReport.jsx @@ -0,0 +1,234 @@ +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 { 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"; + +const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe']; + +export default function StaffingCostReport({ events, invoices }) { + const [dateRange, setDateRange] = useState("30"); + const { toast } = useToast(); + + // Calculate costs by month + const costsByMonth = events.reduce((acc, event) => { + if (!event.date || !event.total) return acc; + const date = new Date(event.date); + const month = date.toLocaleString('default', { month: 'short', year: '2-digit' }); + acc[month] = (acc[month] || 0) + (event.total || 0); + return acc; + }, {}); + + const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({ + month, + cost: Math.round(cost), + budget: Math.round(cost * 1.1), // 10% buffer + })); + + // Costs by department + const costsByDepartment = events.reduce((acc, event) => { + event.shifts?.forEach(shift => { + shift.roles?.forEach(role => { + const dept = role.department || 'Unassigned'; + acc[dept] = (acc[dept] || 0) + (role.total_value || 0); + }); + }); + return acc; + }, {}); + + const departmentData = Object.entries(costsByDepartment) + .map(([name, value]) => ({ name, value: Math.round(value) })) + .sort((a, b) => b.value - a.value); + + // Budget adherence + const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0); + const totalBudget = totalSpent * 1.15; // Assume 15% buffer + const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0; + + const handleExport = () => { + const data = { + summary: { + totalSpent: totalSpent.toFixed(2), + totalBudget: totalBudget.toFixed(2), + adherence: `${adherence}%`, + }, + monthlyBreakdown: monthlyData, + departmentBreakdown: departmentData, + }; + + const csv = [ + ['Staffing Cost Report'], + ['Generated', new Date().toISOString()], + [''], + ['Summary'], + ['Total Spent', totalSpent.toFixed(2)], + ['Total Budget', totalBudget.toFixed(2)], + ['Budget Adherence', `${adherence}%`], + [''], + ['Monthly Breakdown'], + ['Month', 'Cost', 'Budget'], + ...monthlyData.map(d => [d.month, d.cost, d.budget]), + [''], + ['Department Breakdown'], + ['Department', 'Cost'], + ...departmentData.map(d => [d.name, d.value]), + ].map(row => row.join(',')).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" }); + }; + + return ( +
+
+
+

Staffing Costs & Budget Adherence

+

Track spending and budget compliance

+
+
+ + +
+
+ + {/* Summary Cards */} +
+ + +
+
+

Total Spent

+

${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}

+
+
+ +
+
+
+
+ + + +
+
+

Budget

+

${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}

+
+
+ +
+
+
+
+ + + +
+
+

Budget Adherence

+

{adherence}%

+ + {adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"} + +
+
+ +
+
+
+
+
+ + {/* Monthly Cost Trend */} + + + Monthly Cost Trend + + + + + + + + `$${value.toLocaleString()}`} /> + + + + + + + + + {/* Department Breakdown */} +
+ + + Costs by Department + + + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {departmentData.map((entry, index) => ( + + ))} + + `$${value.toLocaleString()}`} /> + + + + + + + + Department Spending + + +
+ {departmentData.slice(0, 5).map((dept, idx) => ( +
+ {dept.name} + ${dept.value.toLocaleString()} +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/AutomationEngine.jsx b/frontend-web/src/components/scheduling/AutomationEngine.jsx new file mode 100644 index 00000000..ba6267a4 --- /dev/null +++ b/frontend-web/src/components/scheduling/AutomationEngine.jsx @@ -0,0 +1,211 @@ +import React, { useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useToast } from "@/components/ui/use-toast"; +import { hasTimeOverlap, checkDoubleBooking } from "./SmartAssignmentEngine"; +import { format, addDays } from "date-fns"; + +/** + * Automation Engine + * Handles background automations to reduce manual work + */ + +export function AutomationEngine() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data: events } = useQuery({ + queryKey: ['events-automation'], + queryFn: () => base44.entities.Event.list(), + initialData: [], + refetchInterval: 30000, // Check every 30s + }); + + const { data: allStaff } = useQuery({ + queryKey: ['staff-automation'], + queryFn: () => base44.entities.Staff.list(), + initialData: [], + refetchInterval: 60000, + }); + + const { data: existingInvoices } = useQuery({ + queryKey: ['invoices-automation'], + queryFn: () => base44.entities.Invoice.list(), + initialData: [], + refetchInterval: 60000, + }); + + // Auto-create invoice when event is marked as Completed + useEffect(() => { + const autoCreateInvoices = async () => { + const completedEvents = events.filter(e => + e.status === 'Completed' && + !e.invoice_id && + !existingInvoices.some(inv => inv.event_id === e.id) + ); + + for (const event of completedEvents) { + try { + const invoiceNumber = `INV-${format(new Date(), 'yyMMddHHmmss')}`; + const issueDate = format(new Date(), 'yyyy-MM-dd'); + const dueDate = format(addDays(new Date(), 30), 'yyyy-MM-dd'); // Net 30 + + const invoice = await base44.entities.Invoice.create({ + invoice_number: invoiceNumber, + event_id: event.id, + event_name: event.event_name, + business_name: event.business_name || event.client_name, + vendor_name: event.vendor_name, + manager_name: event.client_name, + hub: event.hub, + cost_center: event.cost_center, + amount: event.total || 0, + item_count: event.assigned_staff?.length || 0, + status: 'Open', + issue_date: issueDate, + due_date: dueDate, + notes: `Auto-generated invoice for completed event: ${event.event_name}` + }); + + // Update event with invoice_id + await base44.entities.Event.update(event.id, { + invoice_id: invoice.id + }); + + queryClient.invalidateQueries({ queryKey: ['invoices'] }); + queryClient.invalidateQueries({ queryKey: ['events'] }); + } catch (error) { + console.error('Auto-invoice creation failed:', error); + } + } + }; + + if (events.length > 0) { + autoCreateInvoices(); + } + }, [events, existingInvoices, queryClient]); + + // Auto-confirm workers (24 hours before shift) + useEffect(() => { + const autoConfirmWorkers = async () => { + const now = new Date(); + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const upcomingEvents = events.filter(e => { + const eventDate = new Date(e.date); + return eventDate >= now && eventDate <= tomorrow && e.status === 'Assigned'; + }); + + for (const event of upcomingEvents) { + if (event.assigned_staff?.length > 0) { + try { + await base44.entities.Event.update(event.id, { + status: 'Confirmed' + }); + + // Send confirmation emails + for (const staff of event.assigned_staff) { + await base44.integrations.Core.SendEmail({ + to: staff.email, + subject: `Shift Confirmed - ${event.event_name}`, + body: `Your shift at ${event.event_name} on ${event.date} has been confirmed. See you there!` + }); + } + } catch (error) { + console.error('Auto-confirm failed:', error); + } + } + } + }; + + if (events.length > 0) { + autoConfirmWorkers(); + } + }, [events]); + + // Auto-send reminders (2 hours before shift) + useEffect(() => { + const sendReminders = async () => { + const now = new Date(); + const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000); + + const upcomingEvents = events.filter(e => { + const eventDate = new Date(e.date); + return eventDate >= now && eventDate <= twoHoursLater; + }); + + for (const event of upcomingEvents) { + if (event.assigned_staff?.length > 0 && event.status === 'Confirmed') { + for (const staff of event.assigned_staff) { + try { + await base44.integrations.Core.SendEmail({ + to: staff.email, + subject: `Reminder: Your shift starts in 2 hours`, + body: `Reminder: Your shift at ${event.event_name} starts in 2 hours. Location: ${event.event_location || event.hub}` + }); + } catch (error) { + console.error('Reminder failed:', error); + } + } + } + } + }; + + if (events.length > 0) { + sendReminders(); + } + }, [events]); + + // Auto-detect overlapping shifts + useEffect(() => { + const detectOverlaps = () => { + const conflicts = []; + + allStaff.forEach(staff => { + const staffEvents = events.filter(e => + e.assigned_staff?.some(s => s.staff_id === staff.id) + ); + + for (let i = 0; i < staffEvents.length; i++) { + for (let j = i + 1; j < staffEvents.length; j++) { + const e1 = staffEvents[i]; + const e2 = staffEvents[j]; + + const d1 = new Date(e1.date); + const d2 = new Date(e2.date); + + if (d1.toDateString() === d2.toDateString()) { + const shift1 = e1.shifts?.[0]?.roles?.[0]; + const shift2 = e2.shifts?.[0]?.roles?.[0]; + + if (shift1 && shift2 && hasTimeOverlap(shift1, shift2)) { + conflicts.push({ + staff: staff.employee_name, + event1: e1.event_name, + event2: e2.event_name, + date: e1.date + }); + } + } + } + } + }); + + if (conflicts.length > 0) { + toast({ + title: `⚠️ ${conflicts.length} Double-Booking Detected`, + description: `${conflicts[0].staff} has overlapping shifts`, + variant: "destructive", + }); + } + }; + + if (events.length > 0 && allStaff.length > 0) { + detectOverlaps(); + } + }, [events, allStaff]); + + return null; // Background service +} + +export default AutomationEngine; \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/ConflictDetection.jsx b/frontend-web/src/components/scheduling/ConflictDetection.jsx new file mode 100644 index 00000000..fff0b043 --- /dev/null +++ b/frontend-web/src/components/scheduling/ConflictDetection.jsx @@ -0,0 +1,314 @@ +import React from "react"; +import { format, parseISO, isWithinInterval, addMinutes, subMinutes } from "date-fns"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { AlertTriangle, X, Users, MapPin, Clock } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +/** + * Conflict Detection System + * Detects and alerts users to overlapping event bookings + */ + +// Parse time string (HH:MM or HH:MM AM/PM) to minutes since midnight +const parseTimeToMinutes = (timeStr) => { + if (!timeStr) return 0; + + // Handle 24-hour format + if (timeStr.includes(':') && !timeStr.includes('AM') && !timeStr.includes('PM')) { + const [hours, minutes] = timeStr.split(':').map(Number); + return hours * 60 + minutes; + } + + // Handle 12-hour format + const [time, period] = timeStr.split(' '); + let [hours, minutes] = time.split(':').map(Number); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return hours * 60 + minutes; +}; + +// Check if two time ranges overlap (considering buffer) +export const detectTimeOverlap = (start1, end1, start2, end2, bufferMinutes = 0) => { + const s1 = parseTimeToMinutes(start1) - bufferMinutes; + const e1 = parseTimeToMinutes(end1) + bufferMinutes; + const s2 = parseTimeToMinutes(start2); + const e2 = parseTimeToMinutes(end2); + + return s1 < e2 && s2 < e1; +}; + +// Check if two dates are the same or overlap (for multi-day events) +export const detectDateOverlap = (event1, event2) => { + const e1Start = event1.is_multi_day ? parseISO(event1.multi_day_start_date) : parseISO(event1.date); + const e1End = event1.is_multi_day ? parseISO(event1.multi_day_end_date) : parseISO(event1.date); + const e2Start = event2.is_multi_day ? parseISO(event2.multi_day_start_date) : parseISO(event2.date); + const e2End = event2.is_multi_day ? parseISO(event2.multi_day_end_date) : parseISO(event2.date); + + return isWithinInterval(e1Start, { start: e2Start, end: e2End }) || + isWithinInterval(e1End, { start: e2Start, end: e2End }) || + isWithinInterval(e2Start, { start: e1Start, end: e1End }) || + isWithinInterval(e2End, { start: e1Start, end: e1End }); +}; + +// Detect staff conflicts +export const detectStaffConflicts = (event, allEvents) => { + const conflicts = []; + + if (!event.assigned_staff || event.assigned_staff.length === 0) { + return conflicts; + } + + const eventTimes = event.shifts?.[0]?.roles?.[0] || {}; + const bufferBefore = event.buffer_time_before || 0; + const bufferAfter = event.buffer_time_after || 0; + + for (const staff of event.assigned_staff) { + for (const otherEvent of allEvents) { + if (otherEvent.id === event.id) continue; + if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue; + + // Check if same staff is assigned + const staffInOther = otherEvent.assigned_staff?.find(s => s.staff_id === staff.staff_id); + if (!staffInOther) continue; + + // Check date overlap + if (!detectDateOverlap(event, otherEvent)) continue; + + // Check time overlap + const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {}; + const hasOverlap = detectTimeOverlap( + eventTimes.start_time, + eventTimes.end_time, + otherTimes.start_time, + otherTimes.end_time, + bufferBefore + bufferAfter + ); + + if (hasOverlap) { + conflicts.push({ + conflict_type: 'staff_overlap', + severity: 'high', + description: `${staff.staff_name} is double-booked with "${otherEvent.event_name}"`, + conflicting_event_id: otherEvent.id, + conflicting_event_name: otherEvent.event_name, + staff_id: staff.staff_id, + staff_name: staff.staff_name, + detected_at: new Date().toISOString(), + }); + } + } + } + + return conflicts; +}; + +// Detect venue conflicts +export const detectVenueConflicts = (event, allEvents) => { + const conflicts = []; + + if (!event.event_location && !event.hub) { + return conflicts; + } + + const eventLocation = event.event_location || event.hub; + const eventTimes = event.shifts?.[0]?.roles?.[0] || {}; + const bufferBefore = event.buffer_time_before || 0; + const bufferAfter = event.buffer_time_after || 0; + + for (const otherEvent of allEvents) { + if (otherEvent.id === event.id) continue; + if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue; + + const otherLocation = otherEvent.event_location || otherEvent.hub; + if (!otherLocation) continue; + + // Check if same location + if (eventLocation.toLowerCase() !== otherLocation.toLowerCase()) continue; + + // Check date overlap + if (!detectDateOverlap(event, otherEvent)) continue; + + // Check time overlap + const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {}; + const hasOverlap = detectTimeOverlap( + eventTimes.start_time, + eventTimes.end_time, + otherTimes.start_time, + otherTimes.end_time, + bufferBefore + bufferAfter + ); + + if (hasOverlap) { + conflicts.push({ + conflict_type: 'venue_overlap', + severity: 'medium', + description: `Venue "${eventLocation}" is already booked for "${otherEvent.event_name}"`, + conflicting_event_id: otherEvent.id, + conflicting_event_name: otherEvent.event_name, + location: eventLocation, + detected_at: new Date().toISOString(), + }); + } + } + + return conflicts; +}; + +// Detect buffer time violations +export const detectBufferViolations = (event, allEvents) => { + const conflicts = []; + + if (!event.buffer_time_before && !event.buffer_time_after) { + return conflicts; + } + + const eventTimes = event.shifts?.[0]?.roles?.[0] || {}; + + for (const otherEvent of allEvents) { + if (otherEvent.id === event.id) continue; + if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue; + + // Check if events share staff + const sharedStaff = event.assigned_staff?.filter(s => + otherEvent.assigned_staff?.some(os => os.staff_id === s.staff_id) + ) || []; + + if (sharedStaff.length === 0) continue; + + // Check date overlap + if (!detectDateOverlap(event, otherEvent)) continue; + + // Check if buffer time is violated + const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {}; + const eventStart = parseTimeToMinutes(eventTimes.start_time); + const eventEnd = parseTimeToMinutes(eventTimes.end_time); + const otherStart = parseTimeToMinutes(otherTimes.start_time); + const otherEnd = parseTimeToMinutes(otherTimes.end_time); + + const bufferBefore = event.buffer_time_before || 0; + const bufferAfter = event.buffer_time_after || 0; + + const hasViolation = + (otherEnd > eventStart - bufferBefore && otherEnd <= eventStart) || + (otherStart < eventEnd + bufferAfter && otherStart >= eventEnd); + + if (hasViolation) { + conflicts.push({ + conflict_type: 'time_buffer', + severity: 'low', + description: `Buffer time violation with "${otherEvent.event_name}" (${sharedStaff.length} shared staff)`, + conflicting_event_id: otherEvent.id, + conflicting_event_name: otherEvent.event_name, + buffer_required: `${bufferBefore + bufferAfter} minutes`, + detected_at: new Date().toISOString(), + }); + } + } + + return conflicts; +}; + +// Main conflict detection function +export const detectAllConflicts = (event, allEvents) => { + if (!event.conflict_detection_enabled) return []; + + const staffConflicts = detectStaffConflicts(event, allEvents); + const venueConflicts = detectVenueConflicts(event, allEvents); + const bufferViolations = detectBufferViolations(event, allEvents); + + return [...staffConflicts, ...venueConflicts, ...bufferViolations]; +}; + +// Conflict Alert Component +export function ConflictAlert({ conflicts, onDismiss }) { + if (!conflicts || conflicts.length === 0) return null; + + const getSeverityColor = (severity) => { + switch (severity) { + case 'critical': return 'border-red-600 bg-red-50'; + case 'high': return 'border-orange-500 bg-orange-50'; + case 'medium': return 'border-amber-500 bg-amber-50'; + case 'low': return 'border-blue-500 bg-blue-50'; + default: return 'border-slate-300 bg-slate-50'; + } + }; + + const getSeverityIcon = (severity) => { + switch (severity) { + case 'critical': + case 'high': return ; + case 'medium': return ; + case 'low': return ; + default: return ; + } + }; + + const getConflictIcon = (type) => { + switch (type) { + case 'staff_overlap': return ; + case 'venue_overlap': return ; + case 'time_buffer': return ; + default: return null; + } + }; + + return ( +
+ {conflicts.map((conflict, idx) => ( + +
+
+ {getSeverityIcon(conflict.severity)} +
+
+
+ {getConflictIcon(conflict.conflict_type)} + + {conflict.conflict_type.replace('_', ' ')} + + + {conflict.severity.toUpperCase()} + +
+ + {conflict.description} + + {conflict.buffer_required && ( +

+ Buffer required: {conflict.buffer_required} +

+ )} +
+ {onDismiss && ( + + )} +
+
+ ))} +
+ ); +} + +export default { + detectTimeOverlap, + detectDateOverlap, + detectStaffConflicts, + detectVenueConflicts, + detectBufferViolations, + detectAllConflicts, + ConflictAlert, +}; \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/DragDropScheduler.jsx b/frontend-web/src/components/scheduling/DragDropScheduler.jsx new file mode 100644 index 00000000..9592fa18 --- /dev/null +++ b/frontend-web/src/components/scheduling/DragDropScheduler.jsx @@ -0,0 +1,255 @@ +import React, { useState } from "react"; +import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Calendar, Clock, MapPin, Star } from "lucide-react"; +import { format } from "date-fns"; + +/** + * Drag & Drop Scheduler Widget + * Interactive visual scheduler for easy staff assignment + */ + +export default function DragDropScheduler({ events, staff, onAssign, onUnassign }) { + const [localEvents, setLocalEvents] = useState(events || []); + const [localStaff, setLocalStaff] = useState(staff || []); + + const handleDragEnd = (result) => { + const { source, destination, draggableId } = result; + + if (!destination) return; + + // Dragging from unassigned to event + if (source.droppableId === "unassigned" && destination.droppableId.startsWith("event-")) { + const eventId = destination.droppableId.replace("event-", ""); + const staffMember = localStaff.find(s => s.id === draggableId); + + if (staffMember && onAssign) { + onAssign(eventId, staffMember); + + // Update local state + setLocalStaff(prev => prev.filter(s => s.id !== draggableId)); + setLocalEvents(prev => prev.map(e => { + if (e.id === eventId) { + return { + ...e, + assigned_staff: [...(e.assigned_staff || []), { + staff_id: staffMember.id, + staff_name: staffMember.employee_name, + email: staffMember.email, + }] + }; + } + return e; + })); + } + } + + // Dragging from event back to unassigned + if (source.droppableId.startsWith("event-") && destination.droppableId === "unassigned") { + const eventId = source.droppableId.replace("event-", ""); + const event = localEvents.find(e => e.id === eventId); + const staffMember = event?.assigned_staff?.find(s => s.staff_id === draggableId); + + if (staffMember && onUnassign) { + onUnassign(eventId, draggableId); + + // Update local state + setLocalEvents(prev => prev.map(e => { + if (e.id === eventId) { + return { + ...e, + assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId) + }; + } + return e; + })); + + const fullStaff = staff.find(s => s.id === draggableId); + if (fullStaff) { + setLocalStaff(prev => [...prev, fullStaff]); + } + } + } + + // Dragging between events + if (source.droppableId.startsWith("event-") && destination.droppableId.startsWith("event-")) { + const sourceEventId = source.droppableId.replace("event-", ""); + const destEventId = destination.droppableId.replace("event-", ""); + + if (sourceEventId === destEventId) return; + + const sourceEvent = localEvents.find(e => e.id === sourceEventId); + const staffMember = sourceEvent?.assigned_staff?.find(s => s.staff_id === draggableId); + + if (staffMember) { + onUnassign(sourceEventId, draggableId); + onAssign(destEventId, staff.find(s => s.id === draggableId)); + + setLocalEvents(prev => prev.map(e => { + if (e.id === sourceEventId) { + return { + ...e, + assigned_staff: e.assigned_staff.filter(s => s.staff_id !== draggableId) + }; + } + if (e.id === destEventId) { + return { + ...e, + assigned_staff: [...(e.assigned_staff || []), staffMember] + }; + } + return e; + })); + } + } + }; + + return ( + +
+ {/* Unassigned Staff Pool */} + + + Available Staff +

{localStaff.length} unassigned

+
+ + + {(provided, snapshot) => ( +
+ {localStaff.map((s, index) => ( + + {(provided, snapshot) => ( +
+
+ + + {s.employee_name?.charAt(0)} + + +
+

{s.employee_name}

+

{s.position}

+
+ + + {s.rating || 4.5} + + + {s.reliability_score || 95}% reliable + +
+
+
+
+ )} +
+ ))} + {provided.placeholder} + {localStaff.length === 0 && ( +

All staff assigned

+ )} +
+ )} +
+
+
+ + {/* Events Schedule */} +
+ {localEvents.map(event => ( + + +
+
+ {event.event_name} +
+ + + {format(new Date(event.date), 'MMM d, yyyy')} + + + + {event.hub || event.event_location} + +
+
+ = (event.requested || 0) + ? "bg-green-100 text-green-700" + : "bg-amber-100 text-amber-700" + }> + {event.assigned_staff?.length || 0}/{event.requested || 0} filled + +
+
+ + + {(provided, snapshot) => ( +
+
+ {event.assigned_staff?.map((s, index) => ( + + {(provided, snapshot) => ( +
+
+ + + {s.staff_name?.charAt(0)} + + +
+

{s.staff_name}

+

{s.role}

+
+
+
+ )} +
+ ))} +
+ {provided.placeholder} + {(!event.assigned_staff || event.assigned_staff.length === 0) && ( +

+ Drag staff here to assign +

+ )} +
+ )} +
+
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx b/frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx new file mode 100644 index 00000000..b1538bdc --- /dev/null +++ b/frontend-web/src/components/scheduling/SmartAssignmentEngine.jsx @@ -0,0 +1,274 @@ +import React from "react"; +import { base44 } from "@/api/base44Client"; + +/** + * Smart Assignment Engine - Core Logic + * Removes 85% of manual work with intelligent assignment algorithms + */ + +// Calculate worker fatigue based on recent shifts +export const calculateFatigue = (staff, allEvents) => { + const now = new Date(); + const last7Days = allEvents.filter(e => { + const eventDate = new Date(e.date); + const diffDays = (now - eventDate) / (1000 * 60 * 60 * 24); + return diffDays >= 0 && diffDays <= 7 && + e.assigned_staff?.some(s => s.staff_id === staff.id); + }); + + const shiftsLast7Days = last7Days.length; + // Fatigue score: 0 (fresh) to 100 (exhausted) + return Math.min(shiftsLast7Days * 15, 100); +}; + +// Calculate proximity score (0-100, higher is closer) +export const calculateProximity = (staff, eventLocation) => { + if (!staff.hub_location || !eventLocation) return 50; + + // Simple match-based proximity (in production, use geocoding) + if (staff.hub_location.toLowerCase() === eventLocation.toLowerCase()) return 100; + if (staff.hub_location.toLowerCase().includes(eventLocation.toLowerCase()) || + eventLocation.toLowerCase().includes(staff.hub_location.toLowerCase())) return 75; + return 30; +}; + +// Calculate compliance score +export const calculateCompliance = (staff) => { + const hasBackground = staff.background_check_status === 'cleared'; + const hasCertifications = staff.certifications?.length > 0; + const isActive = staff.employment_type && staff.employment_type !== 'Medical Leave'; + + let score = 0; + if (hasBackground) score += 40; + if (hasCertifications) score += 30; + if (isActive) score += 30; + + return score; +}; + +// Calculate cost optimization score +export const calculateCostScore = (staff, role, vendorRates) => { + // Find matching rate for this staff/role + const rate = vendorRates.find(r => + r.vendor_id === staff.vendor_id && + r.role_name === role + ); + + if (!rate) return 50; + + // Lower cost = higher score (inverted) + const avgMarket = rate.market_average || rate.client_rate; + if (!avgMarket) return 50; + + const costRatio = rate.client_rate / avgMarket; + return Math.max(0, Math.min(100, (1 - costRatio) * 100 + 50)); +}; + +// Detect shift time overlaps +export const hasTimeOverlap = (shift1, shift2, bufferMinutes = 30) => { + if (!shift1.start_time || !shift1.end_time || !shift2.start_time || !shift2.end_time) { + return false; + } + + const parseTime = (timeStr) => { + const [time, period] = timeStr.split(' '); + let [hours, minutes] = time.split(':').map(Number); + if (period === 'PM' && hours !== 12) hours += 12; + if (period === 'AM' && hours === 12) hours = 0; + return hours * 60 + minutes; + }; + + const s1Start = parseTime(shift1.start_time); + const s1End = parseTime(shift1.end_time); + const s2Start = parseTime(shift2.start_time); + const s2End = parseTime(shift2.end_time); + + return (s1Start < s2End + bufferMinutes) && (s2Start < s1End + bufferMinutes); +}; + +// Check for double bookings +export const checkDoubleBooking = (staff, event, allEvents) => { + const eventDate = new Date(event.date); + const eventShift = event.shifts?.[0]; + + if (!eventShift) return false; + + const conflicts = allEvents.filter(e => { + if (e.id === event.id) return false; + + const eDate = new Date(e.date); + const sameDay = eDate.toDateString() === eventDate.toDateString(); + if (!sameDay) return false; + + const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id); + if (!isAssigned) return false; + + // Check time overlap + const eShift = e.shifts?.[0]; + if (!eShift) return false; + + return hasTimeOverlap(eventShift.roles?.[0], eShift.roles?.[0]); + }); + + return conflicts.length > 0; +}; + +// Smart Assignment Algorithm +export const smartAssign = async (event, role, allStaff, allEvents, vendorRates, options = {}) => { + const { + prioritizeSkill = true, + prioritizeReliability = true, + prioritizeVendor = true, + prioritizeFatigue = true, + prioritizeCompliance = true, + prioritizeProximity = true, + prioritizeCost = false, + preferredVendorId = null, + clientPreferences = {}, + sectorStandards = {}, + } = options; + + // Filter eligible staff + const eligible = allStaff.filter(staff => { + // Skill match + const hasSkill = staff.position === role.role || staff.position_2 === role.role; + if (!hasSkill) return false; + + // Active status + if (staff.employment_type === 'Medical Leave') return false; + + // Double booking check + if (checkDoubleBooking(staff, event, allEvents)) return false; + + // Sector standards (if any) + if (sectorStandards.minimumRating && (staff.rating || 0) < sectorStandards.minimumRating) { + return false; + } + + return true; + }); + + // Score each eligible staff member + const scored = eligible.map(staff => { + let totalScore = 0; + let weights = 0; + + // Skill match (base score) + const isPrimarySkill = staff.position === role.role; + const skillScore = isPrimarySkill ? 100 : 75; + if (prioritizeSkill) { + totalScore += skillScore * 2; + weights += 2; + } + + // Reliability + if (prioritizeReliability) { + const reliabilityScore = staff.reliability_score || staff.shift_coverage_percentage || 85; + totalScore += reliabilityScore * 1.5; + weights += 1.5; + } + + // Vendor priority + if (prioritizeVendor && preferredVendorId) { + const vendorMatch = staff.vendor_id === preferredVendorId ? 100 : 50; + totalScore += vendorMatch * 1.5; + weights += 1.5; + } + + // Fatigue (lower is better) + if (prioritizeFatigue) { + const fatigueScore = 100 - calculateFatigue(staff, allEvents); + totalScore += fatigueScore * 1; + weights += 1; + } + + // Compliance + if (prioritizeCompliance) { + const complianceScore = calculateCompliance(staff); + totalScore += complianceScore * 1.2; + weights += 1.2; + } + + // Proximity + if (prioritizeProximity) { + const proximityScore = calculateProximity(staff, event.event_location || event.hub); + totalScore += proximityScore * 1; + weights += 1; + } + + // Cost optimization + if (prioritizeCost) { + const costScore = calculateCostScore(staff, role.role, vendorRates); + totalScore += costScore * 1; + weights += 1; + } + + // Client preferences + if (clientPreferences.favoriteStaff?.includes(staff.id)) { + totalScore += 100 * 1.5; + weights += 1.5; + } + if (clientPreferences.blockedStaff?.includes(staff.id)) { + totalScore = 0; // Exclude completely + } + + const finalScore = weights > 0 ? totalScore / weights : 0; + + return { + staff, + score: finalScore, + breakdown: { + skill: skillScore, + reliability: staff.reliability_score || 85, + fatigue: 100 - calculateFatigue(staff, allEvents), + compliance: calculateCompliance(staff), + proximity: calculateProximity(staff, event.event_location || event.hub), + cost: calculateCostScore(staff, role.role, vendorRates), + } + }; + }); + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + + return scored; +}; + +// Auto-fill open shifts +export const autoFillShifts = async (event, allStaff, allEvents, vendorRates, options) => { + const shifts = event.shifts || []; + const assignments = []; + + for (const shift of shifts) { + for (const role of shift.roles || []) { + const needed = (role.count || 0) - (role.assigned || 0); + if (needed <= 0) continue; + + const scored = await smartAssign(event, role, allStaff, allEvents, vendorRates, options); + const selected = scored.slice(0, needed); + + assignments.push(...selected.map(s => ({ + staff_id: s.staff.id, + staff_name: s.staff.employee_name, + email: s.staff.email, + role: role.role, + department: role.department, + shift_name: shift.shift_name, + score: s.score, + }))); + } + } + + return assignments; +}; + +export default { + smartAssign, + autoFillShifts, + calculateFatigue, + calculateProximity, + calculateCompliance, + calculateCostScore, + hasTimeOverlap, + checkDoubleBooking, +}; \ No newline at end of file diff --git a/frontend-web/src/components/scheduling/WorkerInfoCard.jsx b/frontend-web/src/components/scheduling/WorkerInfoCard.jsx new file mode 100644 index 00000000..adc95e11 --- /dev/null +++ b/frontend-web/src/components/scheduling/WorkerInfoCard.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Star, MapPin, Clock, Award, TrendingUp, AlertCircle } from "lucide-react"; + +/** + * Worker Info Hover Card + * Shows comprehensive staff info: role, ratings, history, reliability + */ + +export default function WorkerInfoCard({ staff, trigger }) { + if (!staff) return trigger; + + const reliabilityColor = (score) => { + if (score >= 95) return "text-green-600"; + if (score >= 85) return "text-amber-600"; + return "text-red-600"; + }; + + return ( + + + {trigger} + + +
+ {/* Header */} +
+ + + {staff.employee_name?.charAt(0)} + + +
+

{staff.employee_name}

+

{staff.position}

+ {staff.position_2 && ( +

Also: {staff.position_2}

+ )} +
+
+ + {/* Rating & Reliability */} +
+
+ +
+

Rating

+

{staff.rating || 4.5} ★

+
+
+
+ +
+

Reliability

+

+ {staff.reliability_score || 90}% +

+
+
+
+ + {/* Experience & History */} +
+
+ + + {staff.total_shifts || 0} shifts completed + +
+
+ + {staff.hub_location || staff.city || "Unknown location"} +
+ {staff.certifications && staff.certifications.length > 0 && ( +
+ + + {staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''} + +
+ )} +
+ + {/* Certifications */} + {staff.certifications && staff.certifications.length > 0 && ( +
+

Certifications

+
+ {staff.certifications.slice(0, 3).map((cert, idx) => ( + + {cert.name || cert.cert_name} + + ))} +
+
+ )} + + {/* Performance Indicators */} +
+
+

On-Time

+

+ {staff.shift_coverage_percentage || 95}% +

+
+
+

No-Shows

+

+ {staff.no_show_count || 0} +

+
+
+

Cancels

+

+ {staff.cancellation_count || 0} +

+
+
+ + {/* Warnings */} + {staff.background_check_status !== 'cleared' && ( +
+ +

Background check pending

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/tasks/TaskCard.jsx b/frontend-web/src/components/tasks/TaskCard.jsx new file mode 100644 index 00000000..8635b23d --- /dev/null +++ b/frontend-web/src/components/tasks/TaskCard.jsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { MoreVertical, Paperclip, MessageSquare, Calendar } from "lucide-react"; +import { format } from "date-fns"; + +const priorityConfig = { + high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" }, + normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" }, + low: { bg: "bg-orange-100", text: "text-orange-700", label: "Low" } +}; + +const progressColor = (progress) => { + if (progress >= 75) return "bg-teal-500"; + if (progress >= 50) return "bg-blue-500"; + if (progress >= 25) return "bg-amber-500"; + return "bg-slate-400"; +}; + +export default function TaskCard({ task, provided, onClick }) { + const priority = priorityConfig[task.priority] || priorityConfig.normal; + + return ( + +
+ {/* Header */} +
+

{task.task_name}

+ +
+ + {/* Priority & Date */} +
+ + {priority.label} + + {task.due_date && ( +
+ + {format(new Date(task.due_date), 'd MMM')} +
+ )} +
+ + {/* Progress Bar */} +
+
+
+
+
+ {task.progress || 0}% +
+
+ + {/* Footer */} +
+ {/* Assigned Members */} +
+ {(task.assigned_members || []).slice(0, 3).map((member, idx) => ( + + {member.member_name} + + ))} + {(task.assigned_members?.length || 0) > 3 && ( +
+ +{task.assigned_members.length - 3} +
+ )} +
+ + {/* Stats */} +
+ {(task.attachment_count || 0) > 0 && ( +
+ + {task.attachment_count} +
+ )} + {(task.comment_count || 0) > 0 && ( +
+ + {task.comment_count} +
+ )} +
+
+
+ + ); +} \ No newline at end of file diff --git a/frontend-web/src/components/tasks/TaskColumn.jsx b/frontend-web/src/components/tasks/TaskColumn.jsx new file mode 100644 index 00000000..9fedd525 --- /dev/null +++ b/frontend-web/src/components/tasks/TaskColumn.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Badge } from "@/components/ui/badge"; +import { Plus, MoreVertical } from "lucide-react"; +import { Droppable } from "@hello-pangea/dnd"; + +const columnConfig = { + pending: { bg: "bg-blue-500", label: "Pending" }, + in_progress: { bg: "bg-amber-500", label: "In Progress" }, + on_hold: { bg: "bg-teal-500", label: "On Hold" }, + completed: { bg: "bg-green-500", label: "Completed" } +}; + +export default function TaskColumn({ status, tasks, children, onAddTask }) { + const config = columnConfig[status] || columnConfig.pending; + + return ( +
+ {/* Column Header */} +
+
+ {config.label} + + {tasks.length} + +
+
+ + +
+
+ + {/* Droppable Area */} + + {(provided, snapshot) => ( +
+ {children} + {provided.placeholder} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend-web/src/components/tasks/TaskDetailModal.jsx b/frontend-web/src/components/tasks/TaskDetailModal.jsx new file mode 100644 index 00000000..774db343 --- /dev/null +++ b/frontend-web/src/components/tasks/TaskDetailModal.jsx @@ -0,0 +1,526 @@ +import React, { useState, useRef, useEffect } from "react"; +import { base44 } from "@/api/base44Client"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Avatar } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Calendar, Paperclip, Send, Upload, FileText, Download, AtSign, Smile, Plus, Home, Activity, Mail, Clock, Zap, PauseCircle, CheckCircle } from "lucide-react"; +import { format } from "date-fns"; +import { useToast } from "@/components/ui/use-toast"; + +const priorityConfig = { + high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" }, + normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" }, + low: { bg: "bg-amber-100", text: "text-amber-700", label: "Low" } +}; + +export default function TaskDetailModal({ task, open, onClose }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [comment, setComment] = useState(""); + const [uploading, setUploading] = useState(false); + const [status, setStatus] = useState(task?.status || "pending"); + const [activeTab, setActiveTab] = useState("updates"); + const [emailNotification, setEmailNotification] = useState(false); + const fileInputRef = useRef(null); + + // Auto-calculate progress based on activity + const calculateProgress = () => { + if (!task) return 0; + + let progressScore = 0; + + // Status contributes to progress + if (task.status === "completed") return 100; + if (task.status === "in_progress") progressScore += 40; + if (task.status === "on_hold") progressScore += 20; + + // Comments/updates show activity + if (task.comment_count > 0) progressScore += Math.min(task.comment_count * 5, 20); + + // Files attached show work done + if (task.attachment_count > 0) progressScore += Math.min(task.attachment_count * 10, 20); + + // Assigned members + if (task.assigned_members?.length > 0) progressScore += 20; + + return Math.min(progressScore, 100); + }; + + const currentProgress = calculateProgress(); + + useEffect(() => { + if (task && currentProgress !== task.progress) { + updateTaskMutation.mutate({ + id: task.id, + data: { ...task, progress: currentProgress } + }); + } + }, [currentProgress]); + + const { data: user } = useQuery({ + queryKey: ['current-user-task-modal'], + queryFn: () => base44.auth.me(), + }); + + const { data: comments = [] } = useQuery({ + queryKey: ['task-comments', task?.id], + queryFn: () => base44.entities.TaskComment.filter({ task_id: task?.id }), + enabled: !!task?.id, + initialData: [], + }); + + const addCommentMutation = useMutation({ + mutationFn: (commentData) => base44.entities.TaskComment.create(commentData), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['task-comments', task?.id] }); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + setComment(""); + toast({ + title: "✅ Comment Added", + description: "Your comment has been posted", + }); + }, + }); + + const updateTaskMutation = useMutation({ + mutationFn: ({ id, data }) => base44.entities.Task.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + toast({ + title: "✅ Task Updated", + description: "Changes saved successfully", + }); + }, + }); + + const handleFileUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + try { + const { file_url } = await base44.integrations.Core.UploadFile({ file }); + + const newFile = { + file_name: file.name, + file_url: file_url, + file_size: file.size, + uploaded_by: user?.full_name || user?.email || "User", + uploaded_at: new Date().toISOString(), + }; + + const updatedFiles = [...(task.files || []), newFile]; + + await updateTaskMutation.mutateAsync({ + id: task.id, + data: { + ...task, + files: updatedFiles, + attachment_count: updatedFiles.length, + } + }); + + // Add system comment + await addCommentMutation.mutateAsync({ + task_id: task.id, + author_id: user?.id, + author_name: user?.full_name || user?.email || "User", + author_avatar: user?.profile_picture, + comment: `Uploaded file: ${file.name}`, + is_system: true, + }); + + toast({ + title: "✅ File Uploaded", + description: `${file.name} added successfully`, + }); + } catch (error) { + toast({ + title: "❌ Upload Failed", + description: error.message, + variant: "destructive", + }); + } finally { + setUploading(false); + } + }; + + const handleStatusChange = async (newStatus) => { + setStatus(newStatus); + await updateTaskMutation.mutateAsync({ + id: task.id, + data: { ...task, status: newStatus } + }); + + // Add system comment + await addCommentMutation.mutateAsync({ + task_id: task.id, + author_id: user?.id, + author_name: "System", + author_avatar: "", + comment: `Status changed to ${newStatus.replace('_', ' ')}`, + is_system: true, + }); + }; + + const handleAddComment = async () => { + if (!comment.trim()) return; + + await addCommentMutation.mutateAsync({ + task_id: task.id, + author_id: user?.id, + author_name: user?.full_name || user?.email || "User", + author_avatar: user?.profile_picture, + comment: comment.trim(), + is_system: false, + }); + + // Update comment count + await updateTaskMutation.mutateAsync({ + id: task.id, + data: { + ...task, + comment_count: (task.comment_count || 0) + 1, + } + }); + + // Send email notifications if enabled + if (emailNotification && task.assigned_members) { + for (const member of task.assigned_members) { + try { + await base44.integrations.Core.SendEmail({ + to: member.member_email || `${member.member_name}@example.com`, + subject: `New update on task: ${task.task_name}`, + body: `${user?.full_name || "A team member"} posted an update:\n\n"${comment.trim()}"\n\nView task details in the app.` + }); + } catch (error) { + console.error("Failed to send email:", error); + } + } + toast({ + title: "✅ Update Sent", + description: "Email notifications sent to team members", + }); + } + }; + + const handleMention = () => { + const textarea = document.querySelector('textarea'); + if (textarea) { + const cursorPos = textarea.selectionStart; + const textBefore = comment.substring(0, cursorPos); + const textAfter = comment.substring(cursorPos); + setComment(textBefore + '@' + textAfter); + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(cursorPos + 1, cursorPos + 1); + }, 0); + } + }; + + const handleEmoji = () => { + const emojis = ['👍', '❤️', '😊', '🎉', '✅', '🔥', '💪', '🚀']; + const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)]; + setComment(comment + randomEmoji); + }; + + if (!task) return null; + + const priority = priorityConfig[task.priority] || priorityConfig.normal; + const sortedComments = [...comments].sort((a, b) => + new Date(a.created_date) - new Date(b.created_date) + ); + + const getProgressColor = () => { + if (currentProgress === 100) return "bg-green-500"; + if (currentProgress >= 70) return "bg-blue-500"; + if (currentProgress >= 40) return "bg-amber-500"; + return "bg-slate-400"; + }; + + const statusOptions = [ + { value: "pending", label: "Pending", icon: Clock, color: "bg-slate-100 text-slate-700 border-slate-300" }, + { value: "in_progress", label: "In Progress", icon: Zap, color: "bg-blue-100 text-blue-700 border-blue-300" }, + { value: "on_hold", label: "On Hold", icon: PauseCircle, color: "bg-orange-100 text-orange-700 border-orange-300" }, + { value: "completed", label: "Completed", icon: CheckCircle, color: "bg-green-100 text-green-700 border-green-300" }, + ]; + + return ( + + + {/* Header with Task Info */} +
+
+
+

{task.task_name}

+
+ + {priority.label} Priority + + {task.due_date && ( +
+ + {format(new Date(task.due_date), 'MMM d, yyyy')} +
+ )} +
+
+
+
+ {currentProgress}% +
+
+
+ +
+ {statusOptions.map((option) => { + const IconComponent = option.icon; + return ( + + ); + })} +
+
+ + {/* Assigned Members */} + {task.assigned_members && task.assigned_members.length > 0 && ( +
+ ASSIGNED: +
+ {task.assigned_members.slice(0, 5).map((member, idx) => ( + + {member.member_name} + + ))} + {task.assigned_members.length > 5 && ( +
+ +{task.assigned_members.length - 5} +
+ )} +
+
+ )} +
+ + {/* Tabs */} + + + + + Updates + + + + Files ({task.files?.length || 0}) + + + + Activity Log + + + + {/* Updates Tab */} + +
+
+ Write an update + +
+