Clean old base44 project reference architecture
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
# KROW Workforce - Frontend
|
||||
|
||||
Ce projet contient le code du frontend pour la plateforme KROW Workforce. Il a été initialement prototypé sur la plateforme low-code Base44 et est en cours de migration vers une infrastructure backend personnalisée sur Google Cloud Platform (GCP).
|
||||
|
||||
Ce `README.md` est le guide officiel pour l'équipe de développement. **NE PAS REMPLACER** ce fichier par celui fourni dans les exports de Base44.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
Ce projet utilise un `Makefile` comme point d'entrée principal pour toutes les commandes courantes.
|
||||
|
||||
### Prérequis
|
||||
- Node.js (version LTS recommandée)
|
||||
- npm
|
||||
- `make` (généralement pré-installé sur Linux et macOS)
|
||||
|
||||
### Installation et Lancement
|
||||
1. **Installer les dépendances :**
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
2. **Lancer le serveur de développement :**
|
||||
```bash
|
||||
make dev
|
||||
```
|
||||
L'application sera disponible sur `http://localhost:5173`.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow d'Intégration des Mises à Jour de Base44
|
||||
|
||||
Pour intégrer les nouvelles modifications de l'UI faites par la cliente sur la plateforme Base44, suivez ce processus rigoureux :
|
||||
|
||||
### 1. Valider les Changements d'API
|
||||
Avant d'intégrer le nouveau code, il est impératif de mettre à jour notre documentation API et notre spécification technique. Suivez rigoureusement la procédure détaillée dans le fichier `docs/MAINTENANCE_GUIDE.md`.
|
||||
|
||||
### 2. Intégrer le Nouveau Frontend
|
||||
1. **Créez une branche dédiée** dans Git :
|
||||
```bash
|
||||
git checkout -b integration/base44-update-YYYY-MM-DD
|
||||
```
|
||||
2. **Placez l'export** de Base44 dans un dossier nommé `krow-workforce-web-export-latest` à côté du dossier de ce projet. La structure attendue est :
|
||||
```
|
||||
- /krow-workforce-web/ (ce projet)
|
||||
- /krow-workforce-web-export-latest/ (le nouvel export)
|
||||
```
|
||||
3. **Exécutez la commande d'intégration** pour copier automatiquement les fichiers `src` et `index.html` :
|
||||
```bash
|
||||
make integrate-export
|
||||
```
|
||||
4. **Exécutez le script de préparation** pour neutraliser le SDK Base44 et appliquer nos patchs :
|
||||
```bash
|
||||
make prepare-export
|
||||
```
|
||||
5. **Analysez les différences** avec `git diff`. Intégrez les nouveaux composants et les modifications de l'UI, mais **rejetez les changements** sur les fichiers que nous avons patchés (`src/api/base44Client.js`, `src/main.jsx`, `src/pages/Layout.jsx`) pour conserver notre environnement local fonctionnel. Vérifiez également `package.json` pour toute nouvelle dépendance à ajouter manuellement.
|
||||
6. **Testez l'application** en local avec `make dev` pour vous assurer que tout fonctionne comme prévu.
|
||||
7. Commitez vos changements.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Structure du Projet
|
||||
|
||||
- `scripts/prepare-export.js`: Script de patching pour le workflow hybride.
|
||||
- `docs/`: Contient la documentation du projet (spécification de l'API, guides...).
|
||||
- `src/`: Code source de l'application.
|
||||
- `src/api/`: Contient la configuration du client API (actuellement mocké).
|
||||
- `src/components/`: Composants React réutilisables.
|
||||
- `src/pages/`: Vues principales de l'application, correspondant aux routes.
|
||||
- `src/lib/`: Utilitaires et bibliothèques partagées.
|
||||
- `Makefile`: Orchestrateur des commandes du projet.
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="https://krow-workforce-dev-launchpad.web.app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KROW</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.js", "src/**/*.jsx"]
|
||||
}
|
||||
10408
frontend-web/package-lock.json
generated
10408
frontend-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"name": "base44-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.6",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"framer-motion": "^12.4.7",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"recharts": "^2.15.1",
|
||||
"sonner": "^2.0.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@flydotio/dockerfile": "^0.7.8",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import './App.css'
|
||||
import Pages from "@/pages/index.jsx"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<Pages />
|
||||
<Toaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,76 +0,0 @@
|
||||
// import { createClient } from '@base44/sdk';
|
||||
|
||||
// --- MIGRATION MOCK ---
|
||||
// This mock completely disables the Base44 SDK to allow for local development.
|
||||
// It also simulates user roles for the RoleSwitcher component.
|
||||
|
||||
const MOCK_USER_KEY = 'krow_mock_user_role';
|
||||
|
||||
// Default mock user data
|
||||
const DEFAULT_MOCK_USER = {
|
||||
id: "mock-user-123",
|
||||
email: "dev@example.com",
|
||||
full_name: "Dev User",
|
||||
// 'role' is the Base44 default, 'user_role' is our custom field
|
||||
role: "admin",
|
||||
user_role: "admin", // Default role for testing
|
||||
profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d",
|
||||
};
|
||||
|
||||
// Function to get the current mock user state
|
||||
const getMockUser = () => {
|
||||
try {
|
||||
const storedRole = localStorage.getItem(MOCK_USER_KEY);
|
||||
if (storedRole) {
|
||||
return { ...DEFAULT_MOCK_USER, user_role: storedRole, role: storedRole };
|
||||
}
|
||||
// If no role is stored, set the default and return it
|
||||
localStorage.setItem(MOCK_USER_KEY, DEFAULT_MOCK_USER.user_role);
|
||||
return DEFAULT_MOCK_USER;
|
||||
} catch (e) {
|
||||
// localStorage is not available (e.g., in SSR)
|
||||
return DEFAULT_MOCK_USER;
|
||||
}
|
||||
};
|
||||
|
||||
export const base44 = {
|
||||
auth: {
|
||||
me: () => Promise.resolve(getMockUser()),
|
||||
logout: () => {
|
||||
try {
|
||||
localStorage.removeItem(MOCK_USER_KEY); // Clear role on logout
|
||||
// Optionally, redirect to login page or reload
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
// localStorage is not available
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
updateMe: (data) => {
|
||||
try {
|
||||
if (data.user_role) {
|
||||
localStorage.setItem(MOCK_USER_KEY, data.user_role);
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage is not available
|
||||
}
|
||||
// Simulate a successful update
|
||||
return Promise.resolve({ ...getMockUser(), ...data });
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
ActivityLog: {
|
||||
filter: () => Promise.resolve([]),
|
||||
},
|
||||
// Add other entity mocks as needed for the RoleSwitcher to function
|
||||
// For now, the RoleSwitcher only updates the user role, so other entities might not be critical.
|
||||
},
|
||||
integrations: {
|
||||
Core: {
|
||||
SendEmail: () => Promise.resolve({ status: "sent" }),
|
||||
UploadFile: () => Promise.resolve({ file_url: "mock-file-url" }),
|
||||
InvokeLLM: () => Promise.resolve({ result: "mock-ai-response" }),
|
||||
// Add other integration mocks if the RoleSwitcher indirectly calls them
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { auth } from "../firebase";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL, // You will need to add this to your .env file
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
const user = auth.currentUser;
|
||||
if (user) {
|
||||
const token = await user.getIdToken();
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -1,81 +0,0 @@
|
||||
import { base44 } from './base44Client';
|
||||
|
||||
|
||||
export const Staff = base44.entities.Staff;
|
||||
|
||||
export const Event = base44.entities.Event;
|
||||
|
||||
export const Business = base44.entities.Business;
|
||||
|
||||
export const Shift = base44.entities.Shift;
|
||||
|
||||
export const Conversation = base44.entities.Conversation;
|
||||
|
||||
export const Message = base44.entities.Message;
|
||||
|
||||
export const VendorRate = base44.entities.VendorRate;
|
||||
|
||||
export const VendorDefaultSettings = base44.entities.VendorDefaultSettings;
|
||||
|
||||
export const Invoice = base44.entities.Invoice;
|
||||
|
||||
export const ActivityLog = base44.entities.ActivityLog;
|
||||
|
||||
export const Team = base44.entities.Team;
|
||||
|
||||
export const TeamMember = base44.entities.TeamMember;
|
||||
|
||||
export const TeamHub = base44.entities.TeamHub;
|
||||
|
||||
export const Vendor = base44.entities.Vendor;
|
||||
|
||||
export const Enterprise = base44.entities.Enterprise;
|
||||
|
||||
export const Sector = base44.entities.Sector;
|
||||
|
||||
export const Partner = base44.entities.Partner;
|
||||
|
||||
export const Order = base44.entities.Order;
|
||||
|
||||
export const Assignment = base44.entities.Assignment;
|
||||
|
||||
export const Workforce = base44.entities.Workforce;
|
||||
|
||||
export const RateCard = base44.entities.RateCard;
|
||||
|
||||
export const CompliancePackage = base44.entities.CompliancePackage;
|
||||
|
||||
export const Scorecard = base44.entities.Scorecard;
|
||||
|
||||
export const VendorSectorLink = base44.entities.VendorSectorLink;
|
||||
|
||||
export const VendorPartnerLink = base44.entities.VendorPartnerLink;
|
||||
|
||||
export const OrderVendorInvite = base44.entities.OrderVendorInvite;
|
||||
|
||||
export const Site = base44.entities.Site;
|
||||
|
||||
export const VendorInvite = base44.entities.VendorInvite;
|
||||
|
||||
export const Certification = base44.entities.Certification;
|
||||
|
||||
export const TeamMemberInvite = base44.entities.TeamMemberInvite;
|
||||
|
||||
export const VendorDocumentReview = base44.entities.VendorDocumentReview;
|
||||
|
||||
export const Task = base44.entities.Task;
|
||||
|
||||
export const TaskComment = base44.entities.TaskComment;
|
||||
|
||||
export const WorkerAvailability = base44.entities.WorkerAvailability;
|
||||
|
||||
export const ShiftProposal = base44.entities.ShiftProposal;
|
||||
|
||||
export const VendorRateBook = base44.entities.VendorRateBook;
|
||||
|
||||
export const VendorNetworkApproval = base44.entities.VendorNetworkApproval;
|
||||
|
||||
|
||||
|
||||
// auth sdk:
|
||||
export const User = base44.auth;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { base44 } from './base44Client';
|
||||
|
||||
|
||||
|
||||
|
||||
export const Core = base44.integrations.Core;
|
||||
|
||||
export const InvokeLLM = base44.integrations.Core.InvokeLLM;
|
||||
|
||||
export const SendEmail = base44.integrations.Core.SendEmail;
|
||||
|
||||
export const UploadFile = base44.integrations.Core.UploadFile;
|
||||
|
||||
export const GenerateImage = base44.integrations.Core.GenerateImage;
|
||||
|
||||
export const ExtractDataFromUploadedFile = base44.integrations.Core.ExtractDataFromUploadedFile;
|
||||
|
||||
export const CreateFileSignedUrl = base44.integrations.Core.CreateFileSignedUrl;
|
||||
|
||||
export const UploadPrivateFile = base44.integrations.Core.UploadPrivateFile;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
import apiClient from './client';
|
||||
import { auth, dataConnect } from '../firebase';
|
||||
import { signOut } from 'firebase/auth';
|
||||
|
||||
import * as dcSdk from '@dataconnect/generated'; // listEvents, createEvent, etc.
|
||||
|
||||
// --- Auth Module ---
|
||||
const authModule = {
|
||||
/**
|
||||
* Fetches the currently authenticated user's profile from the backend.
|
||||
* @returns {Promise<object>} The user profile.
|
||||
*/
|
||||
me: async () => {
|
||||
// 1. Firebase auth user
|
||||
const fbUser = auth.currentUser;
|
||||
|
||||
if (!fbUser) {
|
||||
return null; // NO ESTÁ LOGGEADO
|
||||
}
|
||||
|
||||
// 2. Attempt to load matching Krow User from DataConnect
|
||||
// (because your Krow user metadata is stored in the "users" table)
|
||||
let krowUser = null;
|
||||
try {
|
||||
const response = await dcSdk.getUserById(dataConnect, { id: fbUser.uid });
|
||||
krowUser = response.data?.user || null;
|
||||
} catch (err) {
|
||||
console.warn("Krow user not found in DataConnect, returning Firebase-only info.");
|
||||
}
|
||||
|
||||
// 3. Build unified "me" object
|
||||
return {
|
||||
id: fbUser.uid,
|
||||
email: fbUser.email,
|
||||
fullName: krowUser?.fullName || fbUser.displayName || null,
|
||||
role: krowUser?.role || "user",
|
||||
user_role: krowUser?.userRole || null,
|
||||
firebase: fbUser,
|
||||
krow: krowUser
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Logs the user out.
|
||||
* @param {string} [redirectUrl] - Optional URL to redirect to after logout.
|
||||
*/
|
||||
logout: async (redirectUrl) => {
|
||||
await signOut(auth);
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if a user is currently authenticated.
|
||||
* @returns {boolean} True if a user is authenticated.
|
||||
*/
|
||||
isAuthenticated: () => {
|
||||
return !!auth.currentUser;
|
||||
},
|
||||
};
|
||||
|
||||
// --- Core Integrations Module ---
|
||||
const coreIntegrationsModule = {
|
||||
/**
|
||||
* Sends an email.
|
||||
* @param {object} params - { to, subject, body }
|
||||
* @returns {Promise<object>} API response.
|
||||
*/
|
||||
SendEmail: async (params) => {
|
||||
const { data } = await apiClient.post('/sendEmail', params);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Invokes a large language model.
|
||||
* @param {object} params - { prompt, response_json_schema, file_urls }
|
||||
* @returns {Promise<object>} API response.
|
||||
*/
|
||||
InvokeLLM: async (params) => {
|
||||
const { data } = await apiClient.post('/invokeLLM', params);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads a public file.
|
||||
* @param {File} file - The file to upload.
|
||||
* @returns {Promise<object>} API response with file_url.
|
||||
*/
|
||||
UploadFile: async ({ file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await apiClient.post('/uploadFile', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads a private file.
|
||||
* @param {File} file - The file to upload.
|
||||
* @returns {Promise<object>} API response with file_uri.
|
||||
*/
|
||||
UploadPrivateFile: async ({ file }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await apiClient.post('/uploadPrivateFile', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a temporary signed URL for a private file.
|
||||
* @param {object} params - { file_uri, expires_in }
|
||||
* @returns {Promise<object>} API response with signed_url.
|
||||
*/
|
||||
CreateFileSignedUrl: async (params) => {
|
||||
const { data } = await apiClient.post('/createSignedUrl', params);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
const dataconnectEntityConfig = {
|
||||
User: {
|
||||
list: 'listUsers',
|
||||
get: 'getUserById',
|
||||
create: 'createUser',
|
||||
update: 'updateUser',
|
||||
delete: 'deleteUser',
|
||||
filter: 'filterUsers',
|
||||
},
|
||||
Event: {
|
||||
list: 'listEvents',
|
||||
create: 'createEvent',
|
||||
get: 'getEventById',
|
||||
update: 'updateEvent',
|
||||
delete: 'deleteEvent',
|
||||
filter: 'filterEvents',
|
||||
},
|
||||
|
||||
Staff: {
|
||||
list: 'listStaff',
|
||||
create: 'createStaff',
|
||||
get: 'getStaffById',
|
||||
update: 'updateStaff',
|
||||
delete: 'deleteStaff',
|
||||
filter: 'filterStaff',
|
||||
},
|
||||
|
||||
Vendor: {
|
||||
list: 'listVendor',
|
||||
get: 'getVendorById',
|
||||
create: 'createVendor',
|
||||
update: 'updateVendor',
|
||||
delete: 'deleteVendor',
|
||||
filter: 'filterVendors',
|
||||
},
|
||||
|
||||
VendorRate: {
|
||||
list: 'listVendorRate',
|
||||
get: 'getVendorRateById',
|
||||
create: 'createVendorRate',
|
||||
update: 'updateVendorRate',
|
||||
delete: 'deleteVendorRate',
|
||||
filter: 'filterVendorRates',
|
||||
},
|
||||
|
||||
VendorDefaultSetting:{
|
||||
list: 'listVendorDefaultSettings',
|
||||
get: 'getVendorDefaultSettingById',
|
||||
create: 'createVendorDefaultSetting',
|
||||
update: 'updateVendorDefaultSetting',
|
||||
delete: 'deleteVendorDefaultSetting',
|
||||
filter: 'filterVendorDefaultSettings',
|
||||
},
|
||||
|
||||
Invoice:{
|
||||
list: 'listInvoice',
|
||||
get: 'getInvoiceById',
|
||||
create: 'createInvoice',
|
||||
update: 'updateInvoice',
|
||||
delete: 'deleteInvoice',
|
||||
filter: 'filterInvoices',
|
||||
|
||||
},
|
||||
|
||||
Business:{
|
||||
list: 'listBusiness',
|
||||
get: 'getBusinessById',
|
||||
create: 'createBusiness',
|
||||
update: 'updateBusiness',
|
||||
delete: 'deleteBusiness',
|
||||
filter: 'filterBusiness',
|
||||
},
|
||||
|
||||
Certification:{
|
||||
list: 'listCertification',
|
||||
get: 'getCertificationById',
|
||||
create: 'createCertification',
|
||||
update: 'updateCertification',
|
||||
delete: 'deleteCertification',
|
||||
filter: 'filterCertification',
|
||||
},
|
||||
|
||||
Team:{
|
||||
list: 'listTeam',
|
||||
get: 'getTeamById',
|
||||
create: 'createTeam',
|
||||
update: 'updateTeam',
|
||||
delete: 'deleteTeam',
|
||||
filter: 'filterTeam',
|
||||
},
|
||||
|
||||
TeamMember: {
|
||||
list: 'listTeamMember',
|
||||
get: 'getTeamMemberById',
|
||||
create: 'createTeamMember',
|
||||
update: 'updateTeamMember',
|
||||
delete: 'deleteTeamMember',
|
||||
filter: 'filterTeamMember',
|
||||
},
|
||||
|
||||
TeamHub: {
|
||||
list: 'listTeamHub',
|
||||
get: 'getTeamHubById',
|
||||
create: 'createTeamHub',
|
||||
update: 'updateTeamHub',
|
||||
delete: 'deleteTeamHub',
|
||||
filter: 'filterTeamHub',
|
||||
},
|
||||
|
||||
TeamMemberInvite: {
|
||||
list: 'listTeamMemberInvite',
|
||||
get: 'getTeamMemberInviteById',
|
||||
create: 'createTeamMemberInvite',
|
||||
update: 'updateTeamMemberInvite',
|
||||
delete: 'deleteTeamMemberInvite',
|
||||
filter: 'filterTeamMemberInvite',
|
||||
},
|
||||
|
||||
Conversation:{
|
||||
list: 'listConversation',
|
||||
get: 'getConversationById',
|
||||
create: 'createConversation',
|
||||
update: 'updateConversation',
|
||||
delete: 'deleteConversation',
|
||||
filter: 'filterConversation',
|
||||
},
|
||||
|
||||
Message:{
|
||||
list: 'listMessage',
|
||||
get: 'getMessageById',
|
||||
create: 'createMessage',
|
||||
update: 'updateMessage',
|
||||
delete: 'deleteMessage',
|
||||
filter: 'filterMessage',
|
||||
},
|
||||
|
||||
ActivityLog:{
|
||||
list: 'listActivityLog',
|
||||
get: 'getActivityLogById',
|
||||
create: 'createActivityLog',
|
||||
update: 'updateActivityLog',
|
||||
delete: 'deleteActivityLog',
|
||||
filter: 'filterActivityLog',
|
||||
},
|
||||
|
||||
Enterprise:{
|
||||
list: 'listEnterprise',
|
||||
get: 'getEnterpriseById',
|
||||
create: 'createEnterprise',
|
||||
update: 'updateEnterprise',
|
||||
delete: 'deleteEnterprise',
|
||||
filter: 'filterEnterprise',
|
||||
},
|
||||
|
||||
Sector:{
|
||||
|
||||
},
|
||||
|
||||
Partner:{
|
||||
|
||||
},
|
||||
|
||||
Order:{
|
||||
|
||||
},
|
||||
|
||||
Shift:{
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Helper for methods not implemented
|
||||
const notImplemented = (entityName, method) => async () => {
|
||||
throw new Error(`${entityName}.${method} is not implemented yet for Data Connect`);
|
||||
};
|
||||
|
||||
// --- Entities Module ( Data Connect, without REST Base44) ---
|
||||
const entitiesModule = {};
|
||||
|
||||
Object.entries(dataconnectEntityConfig).forEach(([entityName, ops]) => {
|
||||
entitiesModule[entityName] = {
|
||||
|
||||
get: notImplemented(entityName, 'get'),
|
||||
update: notImplemented(entityName, 'update'),
|
||||
delete: notImplemented(entityName, 'delete'),
|
||||
filter: notImplemented(entityName, 'filter'),
|
||||
list: notImplemented(entityName, 'list'),
|
||||
create: notImplemented(entityName, 'create'),
|
||||
|
||||
// list
|
||||
...(ops.list && {
|
||||
list: async (params) => {
|
||||
const fn = dcSdk[ops.list];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.list}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
return fn(dataConnect);
|
||||
},
|
||||
}),
|
||||
|
||||
// create
|
||||
...(ops.create && {
|
||||
create: async (params) => {
|
||||
const fn = dcSdk[ops.create];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.create}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = params ?? {};
|
||||
if (!data) {
|
||||
throw new Error(
|
||||
`${entityName}.create expects a payload like { data: { ...fields } }`
|
||||
);
|
||||
}
|
||||
|
||||
return fn(dataConnect, data);
|
||||
},
|
||||
}),
|
||||
|
||||
//get
|
||||
...(ops.get && {
|
||||
get: async (params) => {
|
||||
const fn = dcSdk[ops.get];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.get}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params || typeof params !== 'object') {
|
||||
throw new Error(`${entityName}.get expects an object of variables (e.g. { id })`);
|
||||
}
|
||||
|
||||
return fn(dataConnect, params);
|
||||
},
|
||||
}),
|
||||
|
||||
//update
|
||||
...(ops.update && {
|
||||
update: async (params) => {
|
||||
const fn = dcSdk[ops.update];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.update}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params || typeof params !== 'object') {
|
||||
throw new Error(
|
||||
`${entityName}.update expects an object of variables matching the GraphQL mutation`
|
||||
);
|
||||
}
|
||||
|
||||
const { id, data } = params;
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`${entityName}.update requires an "id" field`);
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error(
|
||||
`${entityName}.update requires a "data" object with the fields to update`
|
||||
);
|
||||
}
|
||||
|
||||
const vars = { id, ...data };
|
||||
|
||||
return fn(dataConnect, vars);
|
||||
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// delete
|
||||
...(ops.delete && {
|
||||
delete: async (params) => {
|
||||
const fn = dcSdk[ops.delete];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.delete}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params || typeof params !== 'object') {
|
||||
throw new Error(
|
||||
`${entityName}.delete expects an object like { id }`
|
||||
);
|
||||
}
|
||||
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`${entityName}.delete requires an "id" field`);
|
||||
}
|
||||
|
||||
// Data Connect solo espera { id } como variables
|
||||
return fn(dataConnect, { id });
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// filter
|
||||
...(ops.filter && {
|
||||
filter: async (params) => {
|
||||
const fn = dcSdk[ops.filter];
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.filter}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params) {
|
||||
if (ops.list) {//if no params, call to list()
|
||||
const listFn = dcSdk[ops.list];
|
||||
if (typeof listFn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.list}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
return listFn(dataConnect);
|
||||
}
|
||||
throw new Error(`${entityName}.filter expects params or a list operation`);
|
||||
}
|
||||
const rawFilters = params.filters ?? params;
|
||||
const variables = {};
|
||||
|
||||
for (const [key, value] of Object.entries(rawFilters)) {//cleaning undefined/null/'' values
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
variables[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// if no valid filters, call to list()
|
||||
if (Object.keys(variables).length === 0) {
|
||||
if (ops.list) {
|
||||
const listFn = dcSdk[ops.list];
|
||||
if (typeof listFn !== 'function') {
|
||||
throw new Error(
|
||||
`Data Connect operation "${ops.list}" not found for entity "${entityName}".`
|
||||
);
|
||||
}
|
||||
return listFn(dataConnect);
|
||||
}
|
||||
throw new Error(`${entityName}.filter received no valid filters and no list operation`);
|
||||
}
|
||||
|
||||
return fn(dataConnect, variables);
|
||||
},
|
||||
}),
|
||||
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// --- Main SDK Export ---
|
||||
export const krowSDK = {
|
||||
auth: authModule,
|
||||
integrations: {
|
||||
Core: coreIntegrationsModule,
|
||||
},
|
||||
entities: entitiesModule,
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-[#0A39DF] animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-600 font-medium">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function PublicRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-[#0A39DF] animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-600 font-medium">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
DollarSign, TrendingUp, TrendingDown, AlertTriangle, CheckCircle,
|
||||
Target, Lightbulb, ArrowRight, PieChart, BarChart3, Wallet,
|
||||
Building2, Users, Package, Calendar, Zap, Brain, Shield
|
||||
} from "lucide-react";
|
||||
|
||||
const ROLE_BUDGET_CONFIG = {
|
||||
procurement: {
|
||||
title: "Procurement Budget Control",
|
||||
color: "blue",
|
||||
metrics: ["vendor_spend", "rate_compliance", "savings_achieved"],
|
||||
focus: "Vendor rate optimization & consolidation"
|
||||
},
|
||||
operator: {
|
||||
title: "Operator Budget Overview",
|
||||
color: "emerald",
|
||||
metrics: ["sector_allocation", "labor_costs", "efficiency"],
|
||||
focus: "Cross-sector resource optimization"
|
||||
},
|
||||
sector: {
|
||||
title: "Sector Budget Allocation",
|
||||
color: "purple",
|
||||
metrics: ["site_costs", "overtime", "headcount"],
|
||||
focus: "Site-level cost management"
|
||||
},
|
||||
client: {
|
||||
title: "Your Staffing Budget",
|
||||
color: "green",
|
||||
metrics: ["order_costs", "vendor_rates", "savings"],
|
||||
focus: "Cost-effective staffing solutions"
|
||||
},
|
||||
vendor: {
|
||||
title: "Revenue & Margin Tracker",
|
||||
color: "amber",
|
||||
metrics: ["revenue", "margins", "utilization"],
|
||||
focus: "Maximize revenue & worker utilization"
|
||||
},
|
||||
admin: {
|
||||
title: "Platform Budget Intelligence",
|
||||
color: "slate",
|
||||
metrics: ["total_gmv", "platform_fees", "growth"],
|
||||
focus: "Platform-wide financial health"
|
||||
}
|
||||
};
|
||||
|
||||
export default function BudgetUtilizationTracker({
|
||||
userRole = 'admin',
|
||||
events = [],
|
||||
invoices = [],
|
||||
budgetData = null
|
||||
}) {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState("month");
|
||||
const config = ROLE_BUDGET_CONFIG[userRole] || ROLE_BUDGET_CONFIG.admin;
|
||||
|
||||
// Calculate budget metrics
|
||||
const totalSpent = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0) || 15000;
|
||||
const totalBudget = budgetData?.total_budget || totalSpent * 1.2;
|
||||
const utilizationRate = ((totalSpent / totalBudget) * 100).toFixed(1);
|
||||
const remainingBudget = totalBudget - totalSpent;
|
||||
|
||||
// Savings calculation
|
||||
const potentialSavings = totalSpent * 0.12; // 12% potential savings
|
||||
const achievedSavings = totalSpent * 0.05; // 5% already achieved
|
||||
|
||||
// Trend data
|
||||
const trend = utilizationRate < 80 ? "under" : utilizationRate < 95 ? "on_track" : "over";
|
||||
|
||||
// Smart recommendations based on role and budget status
|
||||
const getSmartRecommendations = () => {
|
||||
const recommendations = [];
|
||||
|
||||
if (userRole === 'procurement') {
|
||||
if (utilizationRate > 90) {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Renegotiate top 3 vendor contracts",
|
||||
impact: `Save $${(totalSpent * 0.08).toLocaleString()}`,
|
||||
icon: DollarSign
|
||||
});
|
||||
}
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Consolidate 5 underperforming vendors",
|
||||
impact: "18% rate reduction",
|
||||
icon: Package
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "low",
|
||||
action: "Lock in Q1 rates before price increases",
|
||||
impact: "Protect against 5% inflation",
|
||||
icon: Shield
|
||||
});
|
||||
} else if (userRole === 'operator') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Reallocate 12 workers from Sector A to B",
|
||||
impact: "+15% fill rate improvement",
|
||||
icon: Users
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Reduce overtime in kitchen roles by 20%",
|
||||
impact: `Save $${(totalSpent * 0.04).toLocaleString()}/month`,
|
||||
icon: TrendingDown
|
||||
});
|
||||
} else if (userRole === 'sector') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Review 3 sites with >110% budget usage",
|
||||
impact: "Prevent $8,200 overage",
|
||||
icon: AlertTriangle
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Shift Tuesday/Wednesday staffing levels",
|
||||
impact: "15% efficiency gain",
|
||||
icon: Calendar
|
||||
});
|
||||
} else if (userRole === 'client') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Switch 2 orders to Preferred Vendor rates",
|
||||
impact: `Save $${(totalSpent * 0.06).toLocaleString()}`,
|
||||
icon: DollarSign
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Lock recurring staff for top 3 positions",
|
||||
impact: "15% rate lock guarantee",
|
||||
icon: Shield
|
||||
});
|
||||
} else if (userRole === 'vendor') {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Fill 12 idle workers with pending orders",
|
||||
impact: `+$${(potentialSavings * 0.8).toLocaleString()} revenue`,
|
||||
icon: Users
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Upsell premium rates to 3 new clients",
|
||||
impact: "+8% margin improvement",
|
||||
icon: TrendingUp
|
||||
});
|
||||
} else {
|
||||
recommendations.push({
|
||||
priority: "high",
|
||||
action: "Enable 2 more automation workflows",
|
||||
impact: "+$12K/month platform savings",
|
||||
icon: Zap
|
||||
});
|
||||
recommendations.push({
|
||||
priority: "medium",
|
||||
action: "Approve 5 pending vendor applications",
|
||||
impact: "Unlock $45K GMV potential",
|
||||
icon: Building2
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
};
|
||||
|
||||
const recommendations = getSmartRecommendations();
|
||||
|
||||
// Budget breakdown by category
|
||||
const budgetBreakdown = [
|
||||
{ name: "Labor Costs", amount: totalSpent * 0.65, percentage: 65, color: "bg-blue-500" },
|
||||
{ name: "Vendor Fees", amount: totalSpent * 0.12, percentage: 12, color: "bg-purple-500" },
|
||||
{ name: "Overtime", amount: totalSpent * 0.15, percentage: 15, color: "bg-amber-500" },
|
||||
{ name: "Other", amount: totalSpent * 0.08, percentage: 8, color: "bg-slate-400" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 bg-${config.color}-100 rounded-xl flex items-center justify-center`}>
|
||||
<Wallet className={`w-6 h-6 text-${config.color}-600`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{config.title}</h2>
|
||||
<p className="text-sm text-slate-500">{config.focus}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{["week", "month", "quarter", "year"].map(period => (
|
||||
<Button
|
||||
key={period}
|
||||
size="sm"
|
||||
variant={selectedPeriod === period ? "default" : "outline"}
|
||||
onClick={() => setSelectedPeriod(period)}
|
||||
className={selectedPeriod === period ? "bg-[#0A39DF]" : ""}
|
||||
>
|
||||
{period.charAt(0).toUpperCase() + period.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Budget Card */}
|
||||
<Card className={`border-2 ${trend === 'under' ? 'border-green-200 bg-green-50/30' : trend === 'on_track' ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Total Spent */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Total Spent</p>
|
||||
<p className="text-3xl font-bold text-slate-900">${totalSpent.toLocaleString()}</p>
|
||||
<p className="text-sm text-slate-500">of ${totalBudget.toLocaleString()} budget</p>
|
||||
</div>
|
||||
|
||||
{/* Utilization Rate */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Utilization</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-3xl font-bold ${trend === 'under' ? 'text-green-600' : trend === 'on_track' ? 'text-blue-600' : 'text-red-600'}`}>
|
||||
{utilizationRate}%
|
||||
</p>
|
||||
{trend === 'under' && <TrendingDown className="w-5 h-5 text-green-500" />}
|
||||
{trend === 'on_track' && <Target className="w-5 h-5 text-blue-500" />}
|
||||
{trend === 'over' && <TrendingUp className="w-5 h-5 text-red-500" />}
|
||||
</div>
|
||||
<Progress
|
||||
value={parseFloat(utilizationRate)}
|
||||
className={`h-2 mt-2 ${trend === 'over' ? '[&>div]:bg-red-500' : trend === 'on_track' ? '[&>div]:bg-blue-500' : '[&>div]:bg-green-500'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remaining Budget */}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Remaining</p>
|
||||
<p className={`text-3xl font-bold ${remainingBudget > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${Math.abs(remainingBudget).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{remainingBudget > 0 ? 'Available to spend' : 'Over budget'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Savings Opportunity */}
|
||||
<div className="bg-purple-50 rounded-xl p-4 border-2 border-purple-200">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold mb-1">
|
||||
💡 Savings Potential
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-purple-700">${potentialSavings.toLocaleString()}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge className="bg-green-100 text-green-700 text-[10px]">
|
||||
${achievedSavings.toLocaleString()} achieved
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* AI-Powered Recommendations */}
|
||||
<Card className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white overflow-hidden">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="w-5 h-5" />
|
||||
<span className="font-bold">AI Budget Advisor</span>
|
||||
<Badge className="bg-white/20 text-white border-0 text-[10px]">Smart Recommendations</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{recommendations.map((rec, idx) => {
|
||||
const Icon = rec.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-4 rounded-xl ${rec.priority === 'high' ? 'bg-white/20' : 'bg-white/10'} backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<Badge className={`text-[10px] ${rec.priority === 'high' ? 'bg-red-500' : rec.priority === 'medium' ? 'bg-amber-500' : 'bg-green-500'}`}>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium mb-1">{rec.action}</p>
|
||||
<p className="text-xs text-white/70">{rec.impact}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-2 h-7 text-xs bg-white text-[#0A39DF] hover:bg-white/90"
|
||||
onClick={() => window.location.href = rec.priority === 'high' ? '/VendorManagement' : '/Reports'}
|
||||
>
|
||||
Take Action <ArrowRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChart className="w-5 h-5 text-[#0A39DF]" />
|
||||
Spend Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{budgetBreakdown.map((item, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
<span className="text-sm text-slate-600">${item.amount.toLocaleString()} ({item.percentage}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className={`${item.color} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#0A39DF]" />
|
||||
Budget Health Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-4">
|
||||
<div className={`w-24 h-24 mx-auto rounded-full flex items-center justify-center text-3xl font-bold ${trend === 'under' ? 'bg-green-100 text-green-700' : trend === 'on_track' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{trend === 'under' ? 'A+' : trend === 'on_track' ? 'B+' : 'C'}
|
||||
</div>
|
||||
<p className="mt-3 font-semibold text-slate-900">
|
||||
{trend === 'under' ? 'Excellent Budget Management' : trend === 'on_track' ? 'Good - Monitor Closely' : 'Action Required'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{trend === 'under'
|
||||
? 'You have room for strategic investments'
|
||||
: trend === 'on_track'
|
||||
? 'Stay on track with current spending pace'
|
||||
: 'Immediate cost reduction recommended'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mt-4">
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-green-600">↓12%</p>
|
||||
<p className="text-[10px] text-slate-500">vs Last Period</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-blue-600">94%</p>
|
||||
<p className="text-[10px] text-slate-500">Forecast Accuracy</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<p className="text-lg font-bold text-purple-600">$8.2K</p>
|
||||
<p className="text-[10px] text-slate-500">Saved This Month</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Decision Panel */}
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-purple-900">Quick Decision for {config.title.split(' ')[0]}</p>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
{userRole === 'procurement' && "Lock in 3 vendor contracts at current rates before Q1 price increases — potential 8% savings on $125K annual spend."}
|
||||
{userRole === 'operator' && "Reallocate 15% of Sector B's overtime budget to temporary staffing — same output, 22% cost reduction."}
|
||||
{userRole === 'sector' && "Approve shift consolidation for Tuesdays — reduces 4 overlapping positions, saves $1,800/week."}
|
||||
{userRole === 'client' && "Switch to annual contract with Preferred Vendor — locks in 15% discount, saves $6,400/year."}
|
||||
{userRole === 'vendor' && "Accept 3 pending orders at standard rates — fills idle capacity, adds $12K revenue this month."}
|
||||
{userRole === 'admin' && "Enable automated invoice reconciliation — reduces processing time 60%, saves $8K/month in admin costs."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => window.location.href = '/SavingsEngine'}
|
||||
>
|
||||
Execute <ArrowRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Building2, MapPin, Briefcase, Phone, Mail, TrendingUp, Clock, Award, Users, Eye, Edit2, DollarSign } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function BusinessCard({ company, metrics, isListView = false, onView, onEdit }) {
|
||||
const { companyName, logo, sector, monthlySpend, totalStaff, location, serviceType, phone, email, technology, performance, gradeColor, clientGrade, isActive, lastOrderDate, rateCard, businessId } = company;
|
||||
|
||||
if (isListView) {
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-sm hover:shadow-md transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
{logo ? (
|
||||
<img src={logo} alt={companyName} className="w-16 h-16 rounded-lg object-cover border-2 border-slate-200" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-700 rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Company Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-bold text-slate-900 truncate">{companyName}</h3>
|
||||
<div className={`px-3 py-1 ${gradeColor} rounded-lg font-bold text-white text-sm`}>
|
||||
{clientGrade}
|
||||
</div>
|
||||
{isActive === false && (
|
||||
<Badge className="bg-slate-300 text-slate-700 text-xs">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{serviceType}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{location}
|
||||
</span>
|
||||
<span>Monthly: ${(monthlySpend / 1000).toFixed(0)}k</span>
|
||||
<span>{totalStaff} Staff</span>
|
||||
{lastOrderDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last: {new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Performance Metrics */}
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Cancellations</p>
|
||||
<p className="text-lg font-bold text-orange-700">{performance.cancelRate}%</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">On-Time</p>
|
||||
<p className="text-lg font-bold text-blue-700">{performance.onTimeRate}%</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Rapid Orders</p>
|
||||
<p className="text-lg font-bold text-purple-700">{performance.rapidOrders}</p>
|
||||
</div>
|
||||
<div className="text-center min-w-[80px]">
|
||||
<p className="text-xs text-slate-500 mb-1">Main Position</p>
|
||||
<p className="text-sm font-bold text-green-700 truncate">{performance.mainPosition}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Card Badge */}
|
||||
{rateCard && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border border-emerald-200 hover:bg-emerald-200 cursor-pointer px-3 py-1.5">
|
||||
<DollarSign className="w-3 h-3 mr-1 inline" />
|
||||
{rateCard}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={onView} className="border-blue-600 text-blue-600 hover:bg-blue-50">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="border-slate-300 hover:bg-slate-50">
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-lg hover:shadow-xl transition-all overflow-hidden">
|
||||
{/* Header - Blue Gradient */}
|
||||
<div className="bg-gradient-to-br from-blue-600 to-blue-800 p-6 relative">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{logo ? (
|
||||
<img src={logo} alt={companyName} className="w-16 h-16 rounded-xl object-cover border-2 border-white shadow-md bg-white p-1" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-white rounded-xl flex items-center justify-center shadow-md">
|
||||
<Building2 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-1">{companyName}</h3>
|
||||
<Badge className="bg-white/20 text-white border-white/40 text-xs">
|
||||
# N/A
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-4 py-2 ${gradeColor} rounded-xl font-bold text-2xl text-white shadow-lg`}>
|
||||
{clientGrade}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-blue-100 text-sm font-medium">{serviceType}</p>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-3 gap-3 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Monthly Sales</p>
|
||||
<p className="text-white text-2xl font-bold">${(monthlySpend / 1000).toFixed(0)}k</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Total Staff</p>
|
||||
<p className="text-white text-2xl font-bold">{totalStaff}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
|
||||
<p className="text-blue-200 text-xs font-medium mb-1">Last Order</p>
|
||||
<p className="text-white text-lg font-bold">
|
||||
{lastOrderDate
|
||||
? new Date(lastOrderDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: 'None'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="p-6 bg-slate-50">
|
||||
{/* Company Information */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">COMPANY INFORMATION</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPin className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{location}</p>
|
||||
<p className="text-sm text-slate-500">California</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Briefcase className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{serviceType}</p>
|
||||
<p className="text-sm text-slate-500">Primary Service</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Phone className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-slate-900">{phone}</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className="w-5 h-5 text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-slate-900 break-all">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Card & Technology */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">RATE CARD & TECHNOLOGY</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rateCard ? (
|
||||
<Link to={`${createPageUrl("EditBusiness")}?id=${businessId}&tab=services`}>
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs cursor-pointer hover:bg-emerald-100">
|
||||
<DollarSign className="w-3 h-3 mr-1 inline" />
|
||||
{rateCard}
|
||||
</Badge>
|
||||
</Link>
|
||||
) : (
|
||||
<Badge className="bg-slate-100 text-slate-500 border border-slate-200 text-xs">
|
||||
No Rate Card
|
||||
</Badge>
|
||||
)}
|
||||
{technology?.isUsingKROW ? (
|
||||
<Badge className="bg-blue-50 text-blue-700 border border-blue-200 text-xs">
|
||||
Using KROW
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-50 text-amber-700 border border-amber-200 text-xs cursor-pointer hover:bg-amber-100">
|
||||
Invite to KROW
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">PERFORMANCE METRICS</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Cancellation Rate */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-orange-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Cancellations</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-orange-600 mb-2">{performance.cancelRate}%</p>
|
||||
<div className="w-full h-2 bg-orange-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-orange-600 rounded-full transition-all" style={{ width: `${performance.cancelRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* On-Time Ordering */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">On-Time Order</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-600 mb-2">{performance.onTimeRate}%</p>
|
||||
<div className="w-full h-2 bg-blue-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-600 rounded-full transition-all" style={{ width: `${performance.onTimeRate}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rapid Orders */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Award className="w-4 h-4 text-purple-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Rapid Orders</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-600 mb-2">{performance.rapidOrders}</p>
|
||||
<p className="text-xs text-slate-500">Last 30 days</p>
|
||||
</div>
|
||||
|
||||
{/* Main Position */}
|
||||
<div className="bg-white rounded-lg p-4 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<p className="text-xs font-semibold text-slate-600">Main Position</p>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-green-600">{performance.mainPosition}</p>
|
||||
<p className="text-xs text-slate-500">Most requested</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<button
|
||||
onClick={onView}
|
||||
className="w-full bg-blue-50 hover:bg-blue-100 rounded-lg p-3 text-center transition-colors cursor-pointer"
|
||||
>
|
||||
<p className="text-xs text-blue-700 font-medium">
|
||||
Click to view full profile and detailed analytics
|
||||
</p>
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Save, X } from "lucide-react";
|
||||
|
||||
export default function CreateBusinessModal({ open, onOpenChange, onSubmit, isSubmitting }) {
|
||||
const [formData, setFormData] = useState({
|
||||
business_name: "",
|
||||
company_logo: "", // Added company_logo field
|
||||
contact_name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
hub_building: "",
|
||||
address: "",
|
||||
city: "",
|
||||
area: "Bay Area",
|
||||
sector: "",
|
||||
rate_group: "",
|
||||
status: "Active",
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
// Reset form after submission
|
||||
setFormData({
|
||||
business_name: "",
|
||||
company_logo: "", // Reset company_logo field
|
||||
contact_name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
hub_building: "",
|
||||
address: "",
|
||||
city: "",
|
||||
area: "Bay Area",
|
||||
sector: "",
|
||||
rate_group: "",
|
||||
status: "Active",
|
||||
notes: ""
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold text-[#1C323E]">
|
||||
Create New Business
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
|
||||
{/* Business Name & Company Logo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="business_name" className="text-slate-700 font-medium">
|
||||
Business Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="business_name"
|
||||
value={formData.business_name}
|
||||
onChange={(e) => handleChange('business_name', e.target.value)}
|
||||
placeholder="Enter business name"
|
||||
required
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_logo" className="text-slate-700 font-medium">
|
||||
Company Logo URL
|
||||
</Label>
|
||||
<Input
|
||||
id="company_logo"
|
||||
value={formData.company_logo}
|
||||
onChange={(e) => handleChange('company_logo', e.target.value)}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Optional: URL to company logo image</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary Contact (Moved from first grid to its own section) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact_name" className="text-slate-700 font-medium">
|
||||
Primary Contact <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="contact_name"
|
||||
value={formData.contact_name}
|
||||
onChange={(e) => handleChange('contact_name', e.target.value)}
|
||||
placeholder="Contact name"
|
||||
required
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Contact Number & Email */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-slate-700 font-medium">
|
||||
Contact Number
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="(555) 123-4567"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-slate-700 font-medium">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder="business@example.com"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hub / Building */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hub_building" className="text-slate-700 font-medium">
|
||||
Hub / Building
|
||||
</Label>
|
||||
<Input
|
||||
id="hub_building"
|
||||
value={formData.hub_building}
|
||||
onChange={(e) => handleChange('hub_building', e.target.value)}
|
||||
placeholder="Building name or location"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address" className="text-slate-700 font-medium">
|
||||
Address
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="Street address"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* City & Area */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city" className="text-slate-700 font-medium">
|
||||
City
|
||||
</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
placeholder="City"
|
||||
className="border-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area" className="text-slate-700 font-medium">
|
||||
Area
|
||||
</Label>
|
||||
<Select value={formData.area} onValueChange={(value) => handleChange('area', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select area" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bay Area">Bay Area</SelectItem>
|
||||
<SelectItem value="Southern California">Southern California</SelectItem>
|
||||
<SelectItem value="Northern California">Northern California</SelectItem>
|
||||
<SelectItem value="Central Valley">Central Valley</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sector & Rate Group */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sector" className="text-slate-700 font-medium">
|
||||
Sector
|
||||
</Label>
|
||||
<Select value={formData.sector} onValueChange={(value) => handleChange('sector', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select sector" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bon Appétit">Bon Appétit</SelectItem>
|
||||
<SelectItem value="Eurest">Eurest</SelectItem>
|
||||
<SelectItem value="Aramark">Aramark</SelectItem>
|
||||
<SelectItem value="Epicurean Group">Epicurean Group</SelectItem>
|
||||
<SelectItem value="Chartwells">Chartwells</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate_group" className="text-slate-700 font-medium">
|
||||
Rate Group <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.rate_group} onValueChange={(value) => handleChange('rate_group', value)} required>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select pricing tier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Standard">Standard</SelectItem>
|
||||
<SelectItem value="Premium">Premium</SelectItem>
|
||||
<SelectItem value="Enterprise">Enterprise</SelectItem>
|
||||
<SelectItem value="Custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status" className="text-slate-700 font-medium">
|
||||
Status
|
||||
</Label>
|
||||
<Select value={formData.status} onValueChange={(value) => handleChange('status', value)}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Active">Active</SelectItem>
|
||||
<SelectItem value="Inactive">Inactive</SelectItem>
|
||||
<SelectItem value="Pending">Pending</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSubmitting ? "Creating..." : "Create Business"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Save, Loader2, Link2, Building2, FileText, Mail, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
const ERP_SYSTEMS = [
|
||||
{ value: "None", label: "No ERP Integration" },
|
||||
{ value: "SAP Ariba", label: "SAP Ariba" },
|
||||
{ value: "Fieldglass", label: "SAP Fieldglass" },
|
||||
{ value: "CrunchTime", label: "CrunchTime" },
|
||||
{ value: "Coupa", label: "Coupa" },
|
||||
{ value: "Oracle NetSuite", label: "Oracle NetSuite" },
|
||||
{ value: "Workday", label: "Workday" },
|
||||
{ value: "Other", label: "Other" },
|
||||
];
|
||||
|
||||
const EDI_FORMATS = [
|
||||
{ value: "CSV", label: "CSV (Excel Compatible)" },
|
||||
{ value: "EDI 810", label: "EDI 810 (Standard Invoice)" },
|
||||
{ value: "cXML", label: "cXML (Ariba/Coupa)" },
|
||||
{ value: "JSON", label: "JSON (API Format)" },
|
||||
{ value: "Custom", label: "Custom Template" },
|
||||
];
|
||||
|
||||
export default function ERPSettingsTab({ business, onSave, isSaving }) {
|
||||
const [settings, setSettings] = useState({
|
||||
erp_system: business?.erp_system || "None",
|
||||
erp_vendor_id: business?.erp_vendor_id || "",
|
||||
erp_cost_center: business?.erp_cost_center || "",
|
||||
edi_enabled: business?.edi_enabled || false,
|
||||
edi_format: business?.edi_format || "CSV",
|
||||
invoice_email: business?.invoice_email || "",
|
||||
po_required: business?.po_required || false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (business) {
|
||||
setSettings({
|
||||
erp_system: business.erp_system || "None",
|
||||
erp_vendor_id: business.erp_vendor_id || "",
|
||||
erp_cost_center: business.erp_cost_center || "",
|
||||
edi_enabled: business.edi_enabled || false,
|
||||
edi_format: business.edi_format || "CSV",
|
||||
invoice_email: business.invoice_email || "",
|
||||
po_required: business.po_required || false,
|
||||
});
|
||||
}
|
||||
}, [business]);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setSettings(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(settings);
|
||||
};
|
||||
|
||||
const isConfigured = settings.erp_system !== "None" && settings.erp_vendor_id;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<Card className={`border-2 ${isConfigured ? 'border-green-200 bg-green-50' : 'border-amber-200 bg-amber-50'}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{isConfigured ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-amber-600" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${isConfigured ? 'text-green-900' : 'text-amber-900'}`}>
|
||||
{isConfigured ? 'ERP Integration Active' : 'ERP Integration Not Configured'}
|
||||
</p>
|
||||
<p className={`text-sm ${isConfigured ? 'text-green-700' : 'text-amber-700'}`}>
|
||||
{isConfigured
|
||||
? `Connected to ${settings.erp_system} • Vendor ID: ${settings.erp_vendor_id}`
|
||||
: 'Configure ERP settings to enable automated invoice delivery'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ERP System Settings */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-200">
|
||||
<CardTitle className="flex items-center gap-2 text-slate-900">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
ERP System Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">ERP System</Label>
|
||||
<Select
|
||||
value={settings.erp_system}
|
||||
onValueChange={(value) => handleChange('erp_system', value)}
|
||||
>
|
||||
<SelectTrigger className="border-slate-200">
|
||||
<SelectValue placeholder="Select ERP system" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ERP_SYSTEMS.map(erp => (
|
||||
<SelectItem key={erp.value} value={erp.value}>{erp.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Client's procurement or ERP system</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Vendor ID in ERP</Label>
|
||||
<Input
|
||||
value={settings.erp_vendor_id}
|
||||
onChange={(e) => handleChange('erp_vendor_id', e.target.value)}
|
||||
placeholder="e.g., VND-12345"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Your vendor identifier in client's system</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Default Cost Center</Label>
|
||||
<Input
|
||||
value={settings.erp_cost_center}
|
||||
onChange={(e) => handleChange('erp_cost_center', e.target.value)}
|
||||
placeholder="e.g., CC-1001"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Default cost center for invoice allocation</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Invoice Delivery Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={settings.invoice_email}
|
||||
onChange={(e) => handleChange('invoice_email', e.target.value)}
|
||||
placeholder="ap@client.com"
|
||||
className="border-slate-200"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">Accounts payable email for invoice delivery</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* EDI Settings */}
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-slate-50 border-b border-slate-200">
|
||||
<CardTitle className="flex items-center gap-2 text-slate-900">
|
||||
<Link2 className="w-5 h-5 text-purple-600" />
|
||||
EDI / Export Settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">Enable EDI Integration</p>
|
||||
<p className="text-sm text-slate-500">Automatically format invoices for EDI transmission</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.edi_enabled}
|
||||
onCheckedChange={(checked) => handleChange('edi_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-700 font-medium">Preferred Export Format</Label>
|
||||
<Select
|
||||
value={settings.edi_format}
|
||||
onValueChange={(value) => handleChange('edi_format', value)}
|
||||
>
|
||||
<SelectTrigger className="border-slate-200">
|
||||
<SelectValue placeholder="Select format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EDI_FORMATS.map(format => (
|
||||
<SelectItem key={format.value} value={format.value}>{format.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Default format for invoice exports</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg h-fit">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">PO Required</p>
|
||||
<p className="text-sm text-slate-500">Require PO number on invoices</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.po_required}
|
||||
onCheckedChange={(checked) => handleChange('po_required', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Info */}
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Supported Formats
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-green-100 text-green-700">EDI 810</Badge>
|
||||
<span className="text-blue-700">Standard</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-purple-100 text-purple-700">cXML</Badge>
|
||||
<span className="text-blue-700">Ariba/Coupa</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-orange-100 text-orange-700">CSV</Badge>
|
||||
<span className="text-blue-700">Excel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-slate-100 text-slate-700">JSON</Badge>
|
||||
<span className="text-blue-700">API</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save ERP Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MessageSquare, X, Send, Minimize2, Maximize2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { format } from "date-fns";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function ChatBubble() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [selectedConv, setSelectedConv] = useState(null);
|
||||
const [messageInput, setMessageInput] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: conversations, refetch: refetchConversations } = useQuery({
|
||||
queryKey: ['conversations-bubble'],
|
||||
queryFn: () => base44.entities.Conversation.list('-last_message_at', 5),
|
||||
initialData: [],
|
||||
refetchInterval: 10000, // Refresh every 10 seconds
|
||||
});
|
||||
|
||||
const { data: messages, refetch: refetchMessages } = useQuery({
|
||||
queryKey: ['messages-bubble', selectedConv?.id],
|
||||
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConv?.id }),
|
||||
initialData: [],
|
||||
enabled: !!selectedConv?.id,
|
||||
refetchInterval: 5000, // Refresh every 5 seconds when viewing
|
||||
});
|
||||
|
||||
const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!messageInput.trim() || !selectedConv) return;
|
||||
|
||||
await base44.entities.Message.create({
|
||||
conversation_id: selectedConv.id,
|
||||
sender_id: user.id,
|
||||
sender_name: user.full_name || user.email,
|
||||
sender_role: user.role || "admin",
|
||||
content: messageInput.trim(),
|
||||
read_by: [user.id]
|
||||
});
|
||||
|
||||
await base44.entities.Conversation.update(selectedConv.id, {
|
||||
last_message: messageInput.trim().substring(0, 100),
|
||||
last_message_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setMessageInput("");
|
||||
refetchMessages();
|
||||
refetchConversations();
|
||||
};
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
const colors = {
|
||||
client: "bg-purple-100 text-purple-700",
|
||||
vendor: "bg-amber-100 text-amber-700",
|
||||
staff: "bg-blue-100 text-blue-700",
|
||||
admin: "bg-slate-100 text-slate-700"
|
||||
};
|
||||
return colors[role] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Chat Bubble Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-16 h-16 rounded-full bg-gradient-to-br from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 shadow-2xl relative"
|
||||
>
|
||||
<MessageSquare className="w-7 h-7 text-white" />
|
||||
{totalUnread > 0 && (
|
||||
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white px-2 py-0.5 text-xs">
|
||||
{totalUnread > 9 ? '9+' : totalUnread}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Chat Window */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0, y: 100 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0, opacity: 0, y: 100 }}
|
||||
className="fixed bottom-6 right-6 z-50"
|
||||
style={{
|
||||
width: isMinimized ? '350px' : '400px',
|
||||
height: isMinimized ? 'auto' : '600px'
|
||||
}}
|
||||
>
|
||||
<Card className="shadow-2xl border-2 border-slate-200 overflow-hidden h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<CardHeader className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5 text-[#0A39DF]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white text-base">
|
||||
{selectedConv ? selectedConv.subject : 'Messages'}
|
||||
</CardTitle>
|
||||
{selectedConv && (
|
||||
<p className="text-xs text-white/80">
|
||||
{selectedConv.is_group
|
||||
? `${selectedConv.participants?.length || 0} members`
|
||||
: selectedConv.participants?.[1]?.name || 'Chat'}
|
||||
</p>
|
||||
)}
|
||||
{!selectedConv && (
|
||||
<p className="text-xs text-white/80">
|
||||
{totalUnread > 0 ? `${totalUnread} unread` : 'Online'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedConv && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setSelectedConv(null)}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
>
|
||||
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-white hover:bg-white/20"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{!isMinimized && (
|
||||
<CardContent className="p-0 flex-1 flex flex-col">
|
||||
{!selectedConv ? (
|
||||
<>
|
||||
{/* Conversations List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-2">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-500 text-sm">No conversations yet</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate(createPageUrl("Messages"));
|
||||
}}
|
||||
className="mt-4 bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="sm"
|
||||
>
|
||||
Start a Conversation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const otherParticipant = conv.participants?.[1] || conv.participants?.[0] || {};
|
||||
return (
|
||||
<Card
|
||||
key={conv.id}
|
||||
className="cursor-pointer hover:bg-slate-50 transition-all border border-slate-200"
|
||||
onClick={() => setSelectedConv(conv)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="w-10 h-10 flex-shrink-0">
|
||||
<AvatarFallback className={conv.is_group ? "bg-purple-500 text-white" : "bg-[#0A39DF] text-white"}>
|
||||
{conv.is_group ? <MessageSquare className="w-5 h-5" /> : otherParticipant.name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="font-semibold text-sm truncate">
|
||||
{conv.is_group ? conv.group_name : (conv.subject || otherParticipant.name)}
|
||||
</p>
|
||||
{conv.unread_count > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">
|
||||
{conv.unread_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
{conv.last_message || "No messages yet"}
|
||||
</p>
|
||||
{conv.last_message_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{format(new Date(conv.last_message_at), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Quick Action */}
|
||||
<div className="border-t border-slate-200 p-3 bg-slate-50">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
navigate(createPageUrl("Messages"));
|
||||
}}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="sm"
|
||||
>
|
||||
View All Messages
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Messages Thread */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-3">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === user?.id || message.created_by === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex gap-2 max-w-[80%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<Avatar className="w-7 h-7 flex-shrink-0">
|
||||
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
|
||||
{message.sender_name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`px-3 py-2 rounded-lg ${
|
||||
isOwnMessage
|
||||
? 'bg-[#0A39DF] text-white'
|
||||
: 'bg-slate-100 text-slate-900'
|
||||
}`}>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{message.created_date && format(new Date(message.created_date), "h:mm a")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="border-t border-slate-200 p-3 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Type a message..."
|
||||
value={messageInput}
|
||||
onChange={(e) => setMessageInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!messageInput.trim()}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
size="icon"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Upload, FileText, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function DragDropFileUpload({
|
||||
onFileSelect,
|
||||
accept = ".pdf,.jpg,.jpeg,.png,.doc,.docx",
|
||||
label,
|
||||
hint,
|
||||
uploading = false,
|
||||
uploaded = false,
|
||||
uploadedFileName = null,
|
||||
disabled = false
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 transition-all ${
|
||||
disabled
|
||||
? 'border-slate-200 bg-slate-50 cursor-not-allowed opacity-60'
|
||||
: isDragging
|
||||
? 'border-[#0A39DF] bg-blue-50 scale-[1.02]'
|
||||
: uploaded
|
||||
? 'border-green-300 bg-green-50/30 hover:border-green-400'
|
||||
: 'border-slate-300 hover:border-[#0A39DF] hover:bg-blue-50/30 cursor-pointer'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{label && (
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-lg flex items-center gap-2">
|
||||
<FileText className={`w-5 h-5 ${uploaded ? 'text-green-600' : 'text-[#0A39DF]'}`} />
|
||||
{label}
|
||||
</h3>
|
||||
{hint && <p className="text-sm text-slate-600 mt-1">{hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-[#0A39DF]" />
|
||||
<p className="text-sm text-slate-600">Uploading and validating...</p>
|
||||
</div>
|
||||
) : uploaded ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-green-700">Document uploaded successfully</p>
|
||||
{uploadedFileName && (
|
||||
<p className="text-xs text-slate-500 mt-1">{uploadedFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
<label htmlFor={`file-input-${label}`} className="text-xs text-blue-600 hover:underline cursor-pointer">
|
||||
Upload different file
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Upload className={`w-12 h-12 ${isDragging ? 'text-[#0A39DF] animate-bounce' : 'text-slate-400'}`} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{isDragging ? 'Drop file here' : 'Drag and drop your file here'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">or</p>
|
||||
<label htmlFor={`file-input-${label}`} className="text-sm text-[#0A39DF] hover:underline cursor-pointer font-medium">
|
||||
Browse files
|
||||
</label>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Accepted: {accept.split(',').join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id={`file-input-${label}`}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled || uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export default function GoogleAddressInput({ value, onChange, placeholder = "Enter address...", className = "" }) {
|
||||
const inputRef = useRef(null);
|
||||
const autocompleteRef = useRef(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Google Maps is already loaded
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Google Maps script
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=AIzaSyBkP7xH4NvR6C6vZ8Y3J7qX2QW8Z9vN3Zc&libraries=places`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.onload = () => setIsLoaded(true);
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !inputRef.current) return;
|
||||
|
||||
try {
|
||||
// Initialize Google Maps Autocomplete
|
||||
autocompleteRef.current = new window.google.maps.places.Autocomplete(inputRef.current, {
|
||||
types: ['address'],
|
||||
componentRestrictions: { country: 'us' },
|
||||
});
|
||||
|
||||
// Handle place selection
|
||||
autocompleteRef.current.addListener('place_changed', () => {
|
||||
const place = autocompleteRef.current.getPlace();
|
||||
if (place.formatted_address) {
|
||||
onChange(place.formatted_address);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing Google Maps autocomplete:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autocompleteRef.current) {
|
||||
window.google.maps.event.clearInstanceListeners(autocompleteRef.current);
|
||||
}
|
||||
};
|
||||
}, [isLoaded, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`pl-10 ${className}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions = null,
|
||||
backTo = null,
|
||||
backButtonLabel = "Back"
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{/* Back Button */}
|
||||
{backTo && (
|
||||
<Link to={backTo} className="inline-block mb-4">
|
||||
<Button variant="ghost" className="hover:bg-slate-100">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backButtonLabel}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Main Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-slate-600">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Actions (if provided) */}
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
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";
|
||||
|
||||
// Helper to fix DnD issues
|
||||
const getItemStyle = (isDragging, draggableStyle) => ({
|
||||
userSelect: "none",
|
||||
...draggableStyle,
|
||||
});
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
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 */}
|
||||
<Button
|
||||
onClick={handleOpenCustomizer}
|
||||
variant="outline"
|
||||
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Customize
|
||||
</Button>
|
||||
|
||||
{/* Customizer Dialog */}
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||
Customize Your Dashboard
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Personalize your workspace by adding, removing, and reordering widgets
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* How It Works Banner */}
|
||||
<AnimatePresence>
|
||||
{showHowItWorks && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="font-bold text-blue-900">How it works</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
<strong>Drag</strong> widgets to reorder them
|
||||
</p>
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<strong>Hide</strong> widgets you don't need
|
||||
</p>
|
||||
<p className="text-sm text-blue-800 flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<strong>Show</strong> hidden widgets to bring them back
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHowItWorks(false)}
|
||||
className="text-blue-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Visible Widgets */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-lg text-slate-900">
|
||||
Visible Widgets ({visibleWidgets.length})
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset to Default
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="visible" direction="vertical">
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
|
||||
snapshot.isDraggingOver
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{visibleWidgets.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">No visible widgets</p>
|
||||
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleWidgets.map((widget, index) => (
|
||||
<Draggable key={widget.id} draggableId={widget.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={getItemStyle(
|
||||
snapshot.isDragging,
|
||||
provided.draggableProps.style
|
||||
)}
|
||||
className={`bg-white border-2 rounded-lg p-4 mb-2 ${
|
||||
snapshot.isDragging
|
||||
? 'border-blue-400 shadow-2xl'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{widget.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||
</div>
|
||||
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
|
||||
{widget.category}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => handleHideWidget(widget)}
|
||||
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
|
||||
title="Hide widget"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
||||
{/* Hidden Widgets */}
|
||||
{hiddenWidgets.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
|
||||
Hidden Widgets ({hiddenWidgets.length})
|
||||
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{hiddenWidgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-slate-900">{widget.title}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowWidget(widget)}
|
||||
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
|
||||
title="Show widget"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Hidden Message */}
|
||||
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
|
||||
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
|
||||
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||
<p className="text-sm font-medium text-green-800">
|
||||
All widgets are visible on your dashboard!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
className="text-slate-600"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Badge className="bg-orange-500 text-white animate-pulse">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
||||
className="gap-2"
|
||||
disabled={saveLayoutMutation.isPending}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
{showHowItWorks ? 'Hide' : 'Show'} Help
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saveLayoutMutation.isPending || !hasChanges}
|
||||
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function EcosystemWheel({ layers, onLayerClick, selectedLayer, onLayerHover }) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null);
|
||||
|
||||
const centerX = 300;
|
||||
const centerY = 300;
|
||||
|
||||
// Icon positions - precisely positioned in center of each puzzle piece
|
||||
const iconPositions = [
|
||||
{ x: 385, y: 160 }, // 30° - Buyer (top-right)
|
||||
{ x: 460, y: 300 }, // 60° - Enterprises (top-right)
|
||||
{ x: 380, y: 440 }, // 120° - Sectors (bottom-right)
|
||||
{ x: 220, y: 440 }, // 180° - Partner (bottom-center)
|
||||
{ x: 140, y: 300}, // 240° - Approved Vendor (bottom-left)
|
||||
{ x: 220, y: 160} // 300° - Workforce (top-left)
|
||||
];
|
||||
|
||||
// Create interlocking puzzle pieces
|
||||
const createOuterPuzzle = (index, total) => {
|
||||
const startAngle = index * 360 / total - 90;
|
||||
const endAngle = (index + 1) * 360 / total - 90;
|
||||
const midAngle = (startAngle + endAngle) / 2;
|
||||
|
||||
const innerRadius = 110;
|
||||
const outerRadius = 210;
|
||||
const tabSize = 18;
|
||||
|
||||
const toRad = (deg) => deg * Math.PI / 180;
|
||||
|
||||
const startInner = {
|
||||
x: centerX + innerRadius * Math.cos(toRad(startAngle)),
|
||||
y: centerY + innerRadius * Math.sin(toRad(startAngle))
|
||||
};
|
||||
|
||||
const endInner = {
|
||||
x: centerX + innerRadius * Math.cos(toRad(endAngle)),
|
||||
y: centerY + innerRadius * Math.sin(toRad(endAngle))
|
||||
};
|
||||
|
||||
const startOuter = {
|
||||
x: centerX + outerRadius * Math.cos(toRad(startAngle)),
|
||||
y: centerY + outerRadius * Math.sin(toRad(startAngle))
|
||||
};
|
||||
|
||||
const endOuter = {
|
||||
x: centerX + outerRadius * Math.cos(toRad(endAngle)),
|
||||
y: centerY + outerRadius * Math.sin(toRad(endAngle))
|
||||
};
|
||||
|
||||
const innerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
||||
const outerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
|
||||
|
||||
const innerTabOut = {
|
||||
x: centerX + (innerRadius - tabSize) * Math.cos(toRad(innerTabAngle)),
|
||||
y: centerY + (innerRadius - tabSize) * Math.sin(toRad(innerTabAngle))
|
||||
};
|
||||
|
||||
const outerTabOut = {
|
||||
x: centerX + (outerRadius + tabSize) * Math.cos(toRad(outerTabAngle)),
|
||||
y: centerY + (outerRadius + tabSize) * Math.sin(toRad(outerTabAngle))
|
||||
};
|
||||
|
||||
let path = `M ${startInner.x} ${startInner.y}`;
|
||||
|
||||
const innerArc1End = startAngle + (endAngle - startAngle) * 0.4;
|
||||
const innerArc2Start = startAngle + (endAngle - startAngle) * 0.6;
|
||||
|
||||
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${centerX + innerRadius * Math.cos(toRad(innerArc1End))} ${centerY + innerRadius * Math.sin(toRad(innerArc1End))}`;
|
||||
path += ` Q ${innerTabOut.x} ${innerTabOut.y} ${centerX + innerRadius * Math.cos(toRad(innerArc2Start))} ${centerY + innerRadius * Math.sin(toRad(innerArc2Start))}`;
|
||||
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${endInner.x} ${endInner.y}`;
|
||||
|
||||
path += ` L ${endOuter.x} ${endOuter.y}`;
|
||||
|
||||
const outerArc1End = endAngle - (endAngle - startAngle) * 0.4;
|
||||
const outerArc2Start = endAngle - (endAngle - startAngle) * 0.6;
|
||||
|
||||
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${centerX + outerRadius * Math.cos(toRad(outerArc1End))} ${centerY + outerRadius * Math.sin(toRad(outerArc1End))}`;
|
||||
path += ` Q ${outerTabOut.x} ${outerTabOut.y} ${centerX + outerRadius * Math.cos(toRad(outerArc2Start))} ${centerY + outerRadius * Math.sin(toRad(outerArc2Start))}`;
|
||||
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${startOuter.x} ${startOuter.y}`;
|
||||
|
||||
path += ` L ${startInner.x} ${startInner.y}`;
|
||||
path += " Z";
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const createCenterPuzzle = () => {
|
||||
const radius = 90;
|
||||
const numTabs = layers.length;
|
||||
let path = "";
|
||||
|
||||
for (let i = 0; i < numTabs; i++) {
|
||||
const angle1 = i * 360 / numTabs - 90;
|
||||
const angle2 = (i + 1) * 360 / numTabs - 90;
|
||||
const midAngle = (angle1 + angle2) / 2;
|
||||
const tabSize = 18;
|
||||
|
||||
const toRad = (deg) => deg * Math.PI / 180;
|
||||
|
||||
const start = {
|
||||
x: centerX + radius * Math.cos(toRad(angle1)),
|
||||
y: centerY + radius * Math.sin(toRad(angle1))
|
||||
};
|
||||
|
||||
const end = {
|
||||
x: centerX + radius * Math.cos(toRad(angle2)),
|
||||
y: centerY + radius * Math.sin(toRad(angle2))
|
||||
};
|
||||
|
||||
const tabIn = {
|
||||
x: centerX + (radius + tabSize) * Math.cos(toRad(midAngle)),
|
||||
y: centerY + (radius + tabSize) * Math.sin(toRad(midAngle))
|
||||
};
|
||||
|
||||
if (i === 0) {
|
||||
path += `M ${start.x} ${start.y}`;
|
||||
}
|
||||
|
||||
const arc1End = angle1 + (angle2 - angle1) * 0.4;
|
||||
const arc2Start = angle1 + (angle2 - angle1) * 0.6;
|
||||
|
||||
path += ` A ${radius} ${radius} 0 0 1 ${centerX + radius * Math.cos(toRad(arc1End))} ${centerY + radius * Math.sin(toRad(arc1End))}`;
|
||||
path += ` Q ${tabIn.x} ${tabIn.y} ${centerX + radius * Math.cos(toRad(arc2Start))} ${centerY + radius * Math.sin(toRad(arc2Start))}`;
|
||||
path += ` A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
path += " Z";
|
||||
return path;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col items-center py-8" style={{ minHeight: '700px' }}>
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-2xl font-bold text-[#1C323E] mb-2">KROW Ecosystem</h3>
|
||||
<p className="text-slate-600 text-sm">Hover over each piece to see details • Click to explore</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<svg width="620" height="600" viewBox="0 0 620 600" className="mx-auto drop-shadow-xl">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4" />
|
||||
<feOffset dx="0" dy="3" result="offsetblur" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3" />
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Outer Puzzle Pieces */}
|
||||
{layers.map((layer, index) => {
|
||||
const angle = index * 360 / layers.length - 90;
|
||||
const rad = angle * Math.PI / 180;
|
||||
|
||||
// Use exact icon position from array
|
||||
const iconX = iconPositions[index].x;
|
||||
const iconY = iconPositions[index].y;
|
||||
|
||||
// Label position - Outside puzzle
|
||||
const labelRadius = 240;
|
||||
const labelX = centerX + labelRadius * Math.cos(rad);
|
||||
const labelY = centerY + labelRadius * Math.sin(rad);
|
||||
|
||||
const isHovered = hoveredIndex === index;
|
||||
const Icon = layer.icon;
|
||||
|
||||
return (
|
||||
<g key={index}>
|
||||
{/* Puzzle Piece */}
|
||||
<motion.path
|
||||
d={createOuterPuzzle(index, layers.length)}
|
||||
fill={isHovered ? "#F8FAFC" : "#FFFFFF"}
|
||||
stroke={isHovered ? "#0A39DF" : "#CBD5E1"}
|
||||
strokeWidth={isHovered ? "3" : "2"}
|
||||
filter="url(#shadow)"
|
||||
className="cursor-pointer transition-all duration-300"
|
||||
onMouseEnter={() => {
|
||||
setHoveredIndex(index);
|
||||
onLayerHover?.(layer);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredIndex(null);
|
||||
onLayerHover?.(null);
|
||||
}}
|
||||
onClick={() => onLayerClick?.(layer)}
|
||||
animate={{
|
||||
scale: isHovered ? 1.05 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
transformOrigin: `${centerX}px ${centerY}px`
|
||||
}} />
|
||||
|
||||
|
||||
{/* Icon - Exact positioned in puzzle piece */}
|
||||
<circle
|
||||
cx={iconX}
|
||||
cy={iconY}
|
||||
r="24"
|
||||
fill="#1C323E"
|
||||
className="pointer-events-none transition-all duration-300" />
|
||||
|
||||
<foreignObject
|
||||
x={iconX - 24}
|
||||
y={iconY - 24}
|
||||
width="48"
|
||||
height="48"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icon className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
{/* Label - Outside puzzle */}
|
||||
<foreignObject
|
||||
x={labelX - 90}
|
||||
y={labelY - 25}
|
||||
width="180"
|
||||
height="50"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="bg-white px-5 py-2.5 rounded-xl shadow-lg border border-slate-200">
|
||||
<p className={`font-bold text-sm text-center leading-tight whitespace-nowrap transition-colors ${
|
||||
isHovered ? 'text-[#0A39DF]' : 'text-[#1C323E]'}`
|
||||
}>
|
||||
{layer.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>);
|
||||
|
||||
})}
|
||||
|
||||
{/* Center Puzzle Piece */}
|
||||
<motion.path
|
||||
d={createCenterPuzzle()}
|
||||
fill="#FFFFFF"
|
||||
stroke="#CBD5E1"
|
||||
strokeWidth="2"
|
||||
filter="url(#shadow)"
|
||||
animate={{
|
||||
scale: hoveredIndex !== null ? 1.03 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
transformOrigin: `${centerX}px ${centerY}px`
|
||||
}} />
|
||||
|
||||
|
||||
{/* Center Logo */}
|
||||
<foreignObject
|
||||
x={centerX - 40}
|
||||
y={centerY - 40}
|
||||
width="80"
|
||||
height="80"
|
||||
className="pointer-events-none">
|
||||
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
|
||||
alt="KROW"
|
||||
className="w-16 h-16 object-contain" />
|
||||
|
||||
</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
|
||||
{/* Centered Metrics Display */}
|
||||
<AnimatePresence>
|
||||
{hoveredIndex !== null &&
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50">
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-2xl border-2 border-[#0A39DF] p-6 min-w-[300px]">
|
||||
<div className="flex items-center justify-center gap-3 mb-5 pb-4 border-b border-slate-200">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-md">
|
||||
{React.createElement(layers[hoveredIndex].icon, { className: "w-6 h-6 text-white" })}
|
||||
</div>
|
||||
<h4 className="font-bold text-[#1C323E] text-lg">{layers[hoveredIndex].name}</h4>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(layers[hoveredIndex].metrics).map(([key, value]) =>
|
||||
<div key={key} className="flex justify-between items-center gap-6">
|
||||
<span className="text-slate-600 text-sm capitalize font-medium">
|
||||
{key.replace(/([A-Z])/g, ' $1').trim()}
|
||||
</span>
|
||||
<span className="font-bold text-lg text-[#0A39DF]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 pt-4 border-t border-slate-200 text-center">
|
||||
<p className="text-xs text-slate-500">Click to view full dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function QuickMetrics({ title, description, icon: Icon, metrics, route, gradient }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer group">
|
||||
<CardHeader className={`bg-gradient-to-br ${gradient} border-b border-slate-100`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-[#1C323E] text-base mb-1 flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-[#0A39DF]" />
|
||||
{title}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3 mb-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">{metric.label}</span>
|
||||
<span className={`font-bold text-lg ${metric.color}`}>{metric.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(createPageUrl(route))}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90 group-hover:shadow-md transition-all"
|
||||
>
|
||||
View Dashboard
|
||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings, X, RefreshCw, Shield, Users, Package, Building2, UserCheck, Briefcase, HardHat } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const roles = [
|
||||
{ value: "admin", label: "Administrator", icon: Shield, color: "from-red-500 to-red-700" },
|
||||
{ value: "procurement", label: "Procurement", icon: Package, color: "from-purple-500 to-purple-700" },
|
||||
{ value: "operator", label: "Operator", icon: Users, color: "from-blue-500 to-blue-700" },
|
||||
{ value: "sector", label: "Sector Manager", icon: Building2, color: "from-cyan-500 to-cyan-700" },
|
||||
{ value: "client", label: "Client", icon: Briefcase, color: "from-green-500 to-green-700" },
|
||||
{ value: "vendor", label: "Vendor", icon: Package, color: "from-amber-500 to-amber-700" },
|
||||
{ value: "workforce", label: "Workforce", icon: HardHat, color: "from-slate-500 to-slate-700" },
|
||||
];
|
||||
|
||||
export default function RoleSwitcher() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, refetch } = useQuery({
|
||||
queryKey: ['current-user'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const currentRole = user?.user_role || user?.role || "admin";
|
||||
const currentRoleData = roles.find(r => r.value === currentRole);
|
||||
|
||||
const handleRoleChange = async (newRole) => {
|
||||
try {
|
||||
// Update the user's role
|
||||
await base44.auth.updateMe({
|
||||
user_role: newRole,
|
||||
});
|
||||
|
||||
// Invalidate all queries to refetch with new role
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
// Refetch user data
|
||||
await refetch();
|
||||
|
||||
// Reload the page to apply new role completely
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to switch role:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Toggle Button */}
|
||||
<AnimatePresence>
|
||||
{!isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
className="fixed bottom-24 right-6 z-40"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 hover:from-purple-600 hover:to-purple-800 shadow-lg"
|
||||
title="Role Switcher (Dev Tool)"
|
||||
>
|
||||
<Settings className="w-6 h-6 text-white animate-spin-slow" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Role Switcher Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0, x: 100 }}
|
||||
animate={{ scale: 1, opacity: 1, x: 0 }}
|
||||
exit={{ scale: 0, opacity: 0, x: 100 }}
|
||||
className="fixed bottom-24 right-6 z-40"
|
||||
style={{ width: isMinimized ? '300px' : '400px' }}
|
||||
>
|
||||
<Card className="shadow-2xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<CardHeader className="bg-gradient-to-br from-purple-500 to-purple-700 text-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
<CardTitle className="text-white text-base">Role Switcher</CardTitle>
|
||||
<Badge className="bg-yellow-400 text-yellow-900 text-xs">DEV</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="text-white hover:bg-white/20 h-8 w-8"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white hover:bg-white/20 h-8 w-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Current Role Display */}
|
||||
<div className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg border-2 border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Current Role</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 bg-gradient-to-br ${currentRoleData?.color} rounded-lg flex items-center justify-center`}>
|
||||
{currentRoleData?.icon && <currentRoleData.icon className="w-5 h-5 text-white" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#1C323E]">{currentRoleData?.label}</p>
|
||||
<p className="text-xs text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">
|
||||
Switch to Role
|
||||
</label>
|
||||
<Select value={currentRole} onValueChange={handleRoleChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<role.icon className="w-4 h-4" />
|
||||
<span>{role.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Quick Role Grid */}
|
||||
{!isMinimized && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Quick Switch</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{roles.map((role) => (
|
||||
<Button
|
||||
key={role.value}
|
||||
variant={currentRole === role.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleRoleChange(role.value)}
|
||||
className={currentRole === role.value ? `bg-gradient-to-br ${role.color} text-white` : ""}
|
||||
>
|
||||
<role.icon className="w-4 h-4 mr-2" />
|
||||
{role.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-xs text-yellow-800">
|
||||
<strong>⚠️ Development Tool:</strong> This switcher is for testing only.
|
||||
Changes will persist until you switch again or log out.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{!isMinimized && (
|
||||
<div className="space-y-2 text-xs text-slate-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Admin:</strong> Full access to all features</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Procurement:</strong> Vendor management & orders</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Operator/Sector:</strong> Event & staff management</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Client:</strong> Create orders, view invoices</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Vendor:</strong> Manage own staff & orders</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
|
||||
<p><strong>Workforce:</strong> View shifts & earnings</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } 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 { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Sparkles, Send, Mic, MicOff, Upload, FileText, Clock,
|
||||
MapPin, Users, Calendar, DollarSign, Zap, Brain,
|
||||
TrendingUp, CheckCircle2, Loader2, Eye, MessageSquare,
|
||||
Image as ImageIcon, Wand2, AlertCircle, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function AIOrderAssistant({ onOrderDataExtracted, onClose }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [extractedData, setExtractedData] = useState(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
||||
const messagesEndRef = useRef(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: recentEvents } = useQuery({
|
||||
queryKey: ['recent-events-ai'],
|
||||
queryFn: () => base44.entities.Event.list('-created_date', 5),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const { data: businesses } = useQuery({
|
||||
queryKey: ['businesses-ai'],
|
||||
queryFn: () => base44.entities.Business.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const greeting = {
|
||||
id: Date.now(),
|
||||
role: "assistant",
|
||||
content: "👋 **AI Order Assistant 2030**\n\nI'm your intelligent workforce ordering assistant. Just tell me what you need naturally - I understand context, history, and can create complete orders instantly.\n\n**Try saying:**\n• \"I need 5 servers for Friday night\"\n• \"Repeat last week's order but add 2 bartenders\"\n• \"Same as Event #1234 but different date\"\n\nYou can also upload files, speak naturally, or let me analyze your patterns.",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "greeting"
|
||||
};
|
||||
setMessages([greeting]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Smart suggestions based on context
|
||||
const smartSuggestions = [
|
||||
{
|
||||
icon: RefreshCw,
|
||||
label: "Repeat Last Order",
|
||||
query: "Repeat my last order for next Friday",
|
||||
color: "bg-purple-500"
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
label: "Based on History",
|
||||
query: "Create order based on my usual Friday pattern",
|
||||
color: "bg-blue-500"
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: "Quick Template",
|
||||
query: "I need 3 servers and 2 bartenders for Saturday 6pm-midnight",
|
||||
color: "bg-green-500"
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
label: "AI Predict",
|
||||
query: "What staff will I need for a 200-person corporate event?",
|
||||
color: "bg-orange-500"
|
||||
}
|
||||
];
|
||||
|
||||
const handleSendMessage = async (text = input) => {
|
||||
if (!text.trim() && !isListening) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput("");
|
||||
setIsProcessing(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
try {
|
||||
// Simulate AI processing with intelligent response
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Enhanced AI prompt with context awareness
|
||||
const prompt = `You are an advanced AI workforce ordering assistant in 2030. Analyze this request and extract structured order data.
|
||||
|
||||
User Request: "${text}"
|
||||
|
||||
Recent Order History:
|
||||
${recentEvents.slice(0, 3).map((e, idx) => `${idx + 1}. ${e.event_name} - ${e.business_name} - ${e.requested || 0} staff - ${format(new Date(e.date || Date.now()), 'MMM d, yyyy')}`).join('\n')}
|
||||
|
||||
Available Businesses: ${businesses.slice(0, 5).map(b => b.business_name).join(', ')}
|
||||
|
||||
Instructions:
|
||||
- If user references "last order" or "repeat", use the most recent event data
|
||||
- If user says "usual" or "typical", analyze patterns from history
|
||||
- Be intelligent about inferring missing details (dates, times, quantities)
|
||||
- Suggest optimal staff counts based on event type
|
||||
- Return ONLY valid JSON in this exact format:
|
||||
|
||||
{
|
||||
"event_name": "string",
|
||||
"business_name": "string (from available businesses)",
|
||||
"hub": "string",
|
||||
"date": "YYYY-MM-DD",
|
||||
"order_type": "one_time|rapid|recurring",
|
||||
"shifts": [{
|
||||
"shift_name": "Shift 1",
|
||||
"roles": [{
|
||||
"role": "string (Server, Bartender, Chef, etc)",
|
||||
"count": number,
|
||||
"start_time": "HH:MM",
|
||||
"end_time": "HH:MM",
|
||||
"break_minutes": 30
|
||||
}]
|
||||
}],
|
||||
"notes": "string with any additional context",
|
||||
"ai_confidence": number (0-100),
|
||||
"ai_reasoning": "string explaining the order creation logic"
|
||||
}`;
|
||||
|
||||
const response = await base44.integrations.Core.InvokeLLM({
|
||||
prompt: prompt,
|
||||
add_context_from_internet: false,
|
||||
response_json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: { type: "string" },
|
||||
business_name: { type: "string" },
|
||||
hub: { type: "string" },
|
||||
date: { type: "string" },
|
||||
order_type: { type: "string" },
|
||||
shifts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
shift_name: { type: "string" },
|
||||
roles: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
start_time: { type: "string" },
|
||||
end_time: { type: "string" },
|
||||
break_minutes: { type: "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
notes: { type: "string" },
|
||||
ai_confidence: { type: "number" },
|
||||
ai_reasoning: { type: "string" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const orderData = response;
|
||||
setExtractedData(orderData);
|
||||
|
||||
const assistantMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: `✨ **Order Created Instantly**\n\n${orderData.ai_reasoning}\n\n**Confidence:** ${orderData.ai_confidence}%\n\nReview the order details below and click "Use This Order" to proceed.`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "success",
|
||||
data: orderData
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
|
||||
toast({
|
||||
title: "🎯 AI Order Generated",
|
||||
description: `Created order with ${orderData.shifts?.[0]?.roles?.reduce((sum, r) => sum + (r.count || 0), 0) || 0} staff members`,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: `❌ I couldn't process that request. Please try rephrasing or providing more details.\n\nError: ${error.message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "error"
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceInput = () => {
|
||||
if (!voiceEnabled) {
|
||||
toast({
|
||||
title: "Voice Not Supported",
|
||||
description: "Your browser doesn't support voice input",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
setIsListening(false);
|
||||
// Stop voice recognition
|
||||
} else {
|
||||
setIsListening(true);
|
||||
// Start voice recognition
|
||||
toast({
|
||||
title: "🎤 Listening...",
|
||||
description: "Speak naturally - I understand context and nuance",
|
||||
});
|
||||
|
||||
// Simulate voice input after 3 seconds
|
||||
setTimeout(() => {
|
||||
setIsListening(false);
|
||||
const voiceText = "I need 5 servers and 3 bartenders for this Friday from 6pm to midnight at our downtown location";
|
||||
setInput(voiceText);
|
||||
toast({
|
||||
title: "✅ Voice Captured",
|
||||
description: "Processing your request...",
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
toast({
|
||||
title: "📄 Analyzing Document...",
|
||||
description: "AI is extracting order details from your file",
|
||||
});
|
||||
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
|
||||
const extractResult = await base44.integrations.Core.ExtractDataFromUploadedFile({
|
||||
file_url: file_url,
|
||||
json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: { type: "string" },
|
||||
date: { type: "string" },
|
||||
location: { type: "string" },
|
||||
staff_needs: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string" },
|
||||
count: { type: "number" },
|
||||
hours: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (extractResult.status === "success") {
|
||||
const message = {
|
||||
id: Date.now(),
|
||||
role: "assistant",
|
||||
content: `📄 **Document Analyzed**\n\nI've extracted the following order details:\n\n${JSON.stringify(extractResult.output, null, 2)}\n\nShall I create an order based on this?`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: "file",
|
||||
data: extractResult.output
|
||||
};
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Failed to Process File",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestion = (query) => {
|
||||
setInput(query);
|
||||
handleSendMessage(query);
|
||||
};
|
||||
|
||||
const handleUseOrder = () => {
|
||||
if (extractedData) {
|
||||
onOrderDataExtracted(extractedData);
|
||||
toast({
|
||||
title: "✅ Order Data Applied",
|
||||
description: "Review and edit the order details in the form",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-indigo-50 via-purple-50 to-pink-50 rounded-2xl shadow-2xl border-2 border-indigo-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 p-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
<div className="relative z-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-xl rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Brain className="w-8 h-8 text-white animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1 flex items-center gap-2">
|
||||
AI Order Assistant 2030
|
||||
<Badge className="bg-white/20 text-white border-white/40">
|
||||
<Sparkles className="w-3 h-3 mr-1" />
|
||||
Neural
|
||||
</Badge>
|
||||
</h2>
|
||||
<p className="text-white/90 text-sm">Instant workforce ordering powered by advanced AI</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Real-time Stats Bar */}
|
||||
<div className="mt-4 flex items-center gap-4 text-xs text-white/80">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>3.2s avg response</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>98.7% accuracy</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>5.2x faster than forms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Suggestions - Only show initially */}
|
||||
<AnimatePresence>
|
||||
{showSuggestions && messages.length <= 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="p-4 bg-white/60 backdrop-blur-sm border-b border-indigo-100"
|
||||
>
|
||||
<p className="text-xs font-semibold text-slate-600 mb-3 flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4" />
|
||||
INSTANT ACTIONS
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{smartSuggestions.map((suggestion, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleUseSuggestion(suggestion.query)}
|
||||
className="flex items-center gap-3 p-3 bg-white rounded-xl border border-slate-200 hover:border-indigo-300 hover:shadow-lg transition-all group"
|
||||
>
|
||||
<div className={`w-10 h-10 ${suggestion.color} rounded-lg flex items-center justify-center group-hover:scale-110 transition-transform`}>
|
||||
<suggestion.icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<p className="text-sm font-semibold text-slate-900">{suggestion.label}</p>
|
||||
<p className="text-xs text-slate-500 truncate">{suggestion.query.substring(0, 30)}...</p>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="h-96 overflow-y-auto p-6 space-y-4 bg-white/40 backdrop-blur-sm">
|
||||
<AnimatePresence>
|
||||
{messages.map((message) => (
|
||||
<motion.div
|
||||
key={message.id}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{message.role === 'assistant' && (
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 flex-shrink-0">
|
||||
<AvatarFallback className="bg-transparent">
|
||||
<Brain className="w-5 h-5 text-white" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className={`max-w-2xl ${message.role === 'user' ? 'order-1' : 'order-2'}`}>
|
||||
<div
|
||||
className={`rounded-2xl p-4 shadow-lg ${
|
||||
message.role === 'user'
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-50 border-2 border-red-200'
|
||||
: 'bg-white border-2 border-indigo-100'
|
||||
}`}
|
||||
>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{message.content.split('\n').map((line, idx) => (
|
||||
<p key={idx} className={`mb-1 last:mb-0 ${message.role === 'user' ? 'text-white' : 'text-slate-700'}`}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Preview Card */}
|
||||
{message.type === 'success' && message.data && (
|
||||
<div className="mt-4 p-4 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl border-2 border-green-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-bold text-green-900 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
Order Preview
|
||||
</p>
|
||||
<Badge className="bg-green-600 text-white">
|
||||
{message.data.ai_confidence}% Confidence
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">{message.data.date || 'TBD'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">
|
||||
{message.data.shifts?.[0]?.roles?.reduce((sum, r) => sum + (r.count || 0), 0) || 0} Staff
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">{message.data.hub || message.data.business_name || 'Location TBD'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">
|
||||
{message.data.shifts?.[0]?.roles?.[0]?.start_time || '09:00'} - {message.data.shifts?.[0]?.roles?.[0]?.end_time || '17:00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUseOrder}
|
||||
className="w-full mt-4 bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white font-semibold"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Use This Order
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1 px-2">
|
||||
{format(new Date(message.timestamp), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-slate-500 to-slate-700 flex-shrink-0">
|
||||
<AvatarFallback className="bg-transparent text-white font-bold">
|
||||
U
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600">
|
||||
<AvatarFallback className="bg-transparent">
|
||||
<Brain className="w-5 h-5 text-white animate-pulse" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="bg-white rounded-2xl p-4 shadow-lg border-2 border-indigo-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 text-indigo-600 animate-spin" />
|
||||
<span className="text-sm text-slate-600">
|
||||
AI is analyzing and creating your order...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area - Enhanced */}
|
||||
<div className="p-4 bg-white border-t-2 border-indigo-100">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && handleSendMessage()}
|
||||
placeholder="Type, speak, or upload... AI understands natural language 🧠"
|
||||
className="flex-1 border-2 border-indigo-200 focus:border-indigo-400 rounded-xl h-12 px-4 text-sm"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{/* Voice Button */}
|
||||
<Button
|
||||
onClick={handleVoiceInput}
|
||||
size="icon"
|
||||
variant={isListening ? "default" : "outline"}
|
||||
className={`h-12 w-12 rounded-xl ${
|
||||
isListening
|
||||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||||
: 'border-2 border-indigo-200 hover:border-indigo-400'
|
||||
}`}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isListening ? (
|
||||
<MicOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Mic className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* File Upload */}
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.txt,.csv"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<Button
|
||||
as="span"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-12 w-12 rounded-xl border-2 border-indigo-200 hover:border-indigo-400 cursor-pointer"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={() => handleSendMessage()}
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-xl bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700"
|
||||
disabled={isProcessing || !input.trim()}
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Examples */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span className="font-semibold">Try:</span>
|
||||
<button
|
||||
onClick={() => setInput("I need 3 servers for Friday night 6-11pm")}
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
"3 servers Friday night"
|
||||
</button>
|
||||
<span>•</span>
|
||||
<button
|
||||
onClick={() => setInput("Repeat last week's order")}
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
"Repeat last order"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Capabilities Footer */}
|
||||
<div className="px-4 py-3 bg-gradient-to-r from-indigo-50 to-purple-50 border-t border-indigo-100">
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<Brain className="w-3 h-3 text-indigo-600" />
|
||||
<span>Context-aware</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3 text-purple-600" />
|
||||
<span>Natural language</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3 text-pink-600" />
|
||||
<span>Instant generation</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Powered by Neural AI v3.0
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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";
|
||||
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const updatedEvent = {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
// NEVER MODIFY REQUESTED - it's set by client, not by staff assignment
|
||||
};
|
||||
|
||||
// Auto-update status based on staffing level
|
||||
updatedEvent.status = calculateOrderStatus({
|
||||
...event,
|
||||
...updatedEvent
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, updatedEvent);
|
||||
},
|
||||
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 (
|
||||
<div className="text-center py-6 text-slate-500 text-sm">
|
||||
No staff assigned yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{assignedStaff.map((staff) => (
|
||||
<div
|
||||
key={staff.staff_id}
|
||||
className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<Avatar className="w-10 h-10 bg-gradient-to-br from-blue-400 to-blue-500">
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{staff.staff_name?.charAt(0) || 'S'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900">{staff.staff_name}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{staff.role}
|
||||
</Badge>
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{role.start_time} - {role.end_time}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(staff)}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Edit times"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-slate-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(staff.staff_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="h-8 w-8 p-0"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Edit Times Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Assignment Times</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Staff Member</Label>
|
||||
<p className="text-sm font-medium mt-1">{editTarget?.staff_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Start Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={editTimes.start}
|
||||
onChange={(e) => setEditTimes({ ...editTimes, start: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>End Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={editTimes.end}
|
||||
onChange={(e) => setEditTimes({ ...editTimes, end: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={editMutation.isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{editMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,837 +0,0 @@
|
||||
import React, { useState, useMemo } 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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight, AlertTriangle, RefreshCw, Search } from "lucide-react";
|
||||
import { format, parseISO, isWithinInterval } from "date-fns";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return '';
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
};
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return 'S';
|
||||
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||
};
|
||||
|
||||
const avatarColors = [
|
||||
'bg-blue-500',
|
||||
'bg-purple-500',
|
||||
'bg-green-500',
|
||||
'bg-orange-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
'bg-teal-500',
|
||||
'bg-red-500',
|
||||
];
|
||||
|
||||
// Helper to check if times overlap
|
||||
const hasTimeConflict = (existingStart, existingEnd, newStart, newEnd, existingDate, newDate) => {
|
||||
// If different dates, no conflict
|
||||
if (existingDate !== newDate) return false;
|
||||
|
||||
if (!existingStart || !existingEnd || !newStart || !newEnd) return false;
|
||||
|
||||
const parseTime = (time) => {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
const existingStartMin = parseTime(existingStart);
|
||||
const existingEndMin = parseTime(existingEnd);
|
||||
const newStartMin = parseTime(newStart);
|
||||
const newEndMin = parseTime(newEnd);
|
||||
|
||||
return (newStartMin < existingEndMin && newEndMin > existingStartMin);
|
||||
};
|
||||
|
||||
export default function EventAssignmentModal({ open, onClose, order, onUpdate, isRapid = false }) {
|
||||
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [selectedStaffIds, setSelectedStaffIds] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [swapMode, setSwapMode] = useState(null); // { employeeId, assignmentIndex }
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-assignment'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { data: allOrders = [] } = useQuery({
|
||||
queryKey: ['orders-for-conflict-check'],
|
||||
queryFn: () => base44.entities.Order.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const updateOrderMutation = useMutation({
|
||||
mutationFn: (updatedOrder) => base44.entities.Order.update(order.id, updatedOrder),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] });
|
||||
if (onUpdate) onUpdate();
|
||||
setSelectedStaffIds([]);
|
||||
toast({
|
||||
title: "✅ Staff assigned successfully",
|
||||
description: "The order has been updated with new assignments.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!order || !order.shifts_data || order.shifts_data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentShift = order.shifts_data[selectedShiftIndex];
|
||||
const currentRole = currentShift?.roles[selectedRoleIndex];
|
||||
|
||||
if (!currentRole) return null;
|
||||
|
||||
// Check for conflicts
|
||||
const getStaffConflicts = (staffId) => {
|
||||
const conflicts = [];
|
||||
|
||||
allOrders.forEach(existingOrder => {
|
||||
if (existingOrder.id === order.id) return; // Skip current order
|
||||
if (!existingOrder.shifts_data) return;
|
||||
|
||||
existingOrder.shifts_data.forEach(shift => {
|
||||
shift.roles.forEach(role => {
|
||||
if (!role.assignments) return;
|
||||
|
||||
role.assignments.forEach(assignment => {
|
||||
if (assignment.employee_id === staffId) {
|
||||
const hasConflict = hasTimeConflict(
|
||||
role.start_time,
|
||||
role.end_time,
|
||||
currentRole.start_time,
|
||||
currentRole.end_time,
|
||||
existingOrder.event_date,
|
||||
order.event_date
|
||||
);
|
||||
|
||||
if (hasConflict) {
|
||||
conflicts.push({
|
||||
orderName: existingOrder.event_name,
|
||||
role: role.service,
|
||||
time: `${convertTo12Hour(role.start_time)} - ${convertTo12Hour(role.end_time)}`,
|
||||
date: existingOrder.event_date
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
|
||||
// Role-based filtering
|
||||
const roleKeywords = {
|
||||
'bartender': ['bartender', 'bar'],
|
||||
'cook': ['cook', 'chef', 'kitchen'],
|
||||
'server': ['server', 'waiter', 'waitress'],
|
||||
'cashier': ['cashier', 'register'],
|
||||
'host': ['host', 'hostess', 'greeter'],
|
||||
'busser': ['busser', 'bus'],
|
||||
'dishwasher': ['dishwasher', 'dish'],
|
||||
'manager': ['manager', 'supervisor'],
|
||||
};
|
||||
|
||||
const getRoleCategory = (roleName) => {
|
||||
const lowerRole = roleName.toLowerCase();
|
||||
for (const [category, keywords] of Object.entries(roleKeywords)) {
|
||||
if (keywords.some(keyword => lowerRole.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const matchesRole = (staffPosition, requiredRole) => {
|
||||
const staffLower = (staffPosition || '').toLowerCase();
|
||||
const requiredLower = (requiredRole || '').toLowerCase();
|
||||
|
||||
// Direct match
|
||||
if (staffLower.includes(requiredLower) || requiredLower.includes(staffLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Category match
|
||||
const staffCategory = getRoleCategory(staffPosition || '');
|
||||
const requiredCategory = getRoleCategory(requiredRole);
|
||||
|
||||
return staffCategory && requiredCategory && staffCategory === requiredCategory;
|
||||
};
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedStaffIds.length === 0) {
|
||||
toast({
|
||||
title: "No staff selected",
|
||||
description: "Please select staff members to assign.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const currentAssigned = assignments.length;
|
||||
const remaining = needed - currentAssigned;
|
||||
|
||||
// Strictly enforce the requested count
|
||||
if (remaining <= 0) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflictingStaff = [];
|
||||
selectedStaffIds.forEach(staffId => {
|
||||
const conflicts = getStaffConflicts(staffId);
|
||||
if (conflicts.length > 0) {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
conflictingStaff.push(staff?.employee_name);
|
||||
}
|
||||
});
|
||||
|
||||
if (conflictingStaff.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict Detected",
|
||||
description: `${conflictingStaff.join(', ')} ${conflictingStaff.length === 1 ? 'is' : 'are'} already assigned to overlapping shifts.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStaffIds.length > remaining) {
|
||||
toast({
|
||||
title: "Assignment Limit",
|
||||
description: `Only ${remaining} more staff ${remaining === 1 ? 'is' : 'are'} needed.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add all selected staff
|
||||
const newAssignments = selectedStaffIds.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
employee_id: staff.id,
|
||||
employee_name: staff.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
});
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = [
|
||||
...assignments,
|
||||
...newAssignments
|
||||
];
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleAssignStaff = (staffMember) => {
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Strictly enforce the requested count
|
||||
if (assignments.length >= needed) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This position requested exactly ${needed} staff. Cannot assign more.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
if (assignments.some(a => a.employee_id === staffMember.id)) {
|
||||
toast({
|
||||
title: "Already assigned",
|
||||
description: `${staffMember.employee_name} is already assigned to this role.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(staffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${staffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new assignment
|
||||
const newAssignment = {
|
||||
employee_id: staffMember.id,
|
||||
employee_name: staffMember.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = [
|
||||
...assignments,
|
||||
newAssignment
|
||||
];
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleRemoveStaff = (employeeId) => {
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments =
|
||||
assignments.filter(a => a.employee_id !== employeeId);
|
||||
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
};
|
||||
|
||||
const handleSwapStaff = (newStaffMember) => {
|
||||
if (!swapMode) return;
|
||||
|
||||
const updatedOrder = { ...order };
|
||||
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
|
||||
|
||||
// Check for conflicts
|
||||
const conflicts = getStaffConflicts(newStaffMember.id);
|
||||
if (conflicts.length > 0) {
|
||||
toast({
|
||||
title: "⚠️ Time Conflict",
|
||||
description: `${newStaffMember.employee_name} is already assigned to ${conflicts[0].orderName} at ${conflicts[0].time}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the assignment
|
||||
assignments[swapMode.assignmentIndex] = {
|
||||
employee_id: newStaffMember.id,
|
||||
employee_name: newStaffMember.employee_name,
|
||||
position: currentRole.service,
|
||||
shift_date: order.event_date,
|
||||
shift_start: currentRole.start_time,
|
||||
shift_end: currentRole.end_time,
|
||||
location: currentShift.address || order.event_address,
|
||||
hub_location: order.hub_location,
|
||||
};
|
||||
|
||||
updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments = assignments;
|
||||
updateOrderMutation.mutate(updatedOrder);
|
||||
setSwapMode(null);
|
||||
};
|
||||
|
||||
const assignments = currentRole.assignments || [];
|
||||
const needed = parseInt(currentRole.count) || 0;
|
||||
const assigned = assignments.length;
|
||||
const isFullyStaffed = assigned >= needed;
|
||||
|
||||
// Filter staff by role and exclude already assigned
|
||||
const assignedIds = new Set(assignments.map(a => a.employee_id));
|
||||
const roleFilteredStaff = allStaff.filter(s =>
|
||||
matchesRole(s.position, currentRole.service) ||
|
||||
matchesRole(s.position_2, currentRole.service)
|
||||
);
|
||||
|
||||
const availableStaff = roleFilteredStaff
|
||||
.filter(s => !assignedIds.has(s.id))
|
||||
.filter(s => {
|
||||
if (!searchTerm) return true;
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
return (
|
||||
s.employee_name?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position?.toLowerCase().includes(lowerSearch) ||
|
||||
s.position_2?.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
|
||||
// Calculate total assignments across all roles in this shift
|
||||
let totalNeeded = 0;
|
||||
let totalAssigned = 0;
|
||||
currentShift.roles.forEach(role => {
|
||||
totalNeeded += parseInt(role.count) || 0;
|
||||
totalAssigned += role.assignments?.length || 0;
|
||||
});
|
||||
|
||||
const toggleStaffSelection = (staffId) => {
|
||||
setSelectedStaffIds(prev =>
|
||||
prev.includes(staffId)
|
||||
? prev.filter(id => id !== staffId)
|
||||
: [...prev, staffId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
const remaining = needed - assigned;
|
||||
const selectableStaff = availableStaff.slice(0, remaining);
|
||||
setSelectedStaffIds(selectableStaff.map(s => s.id));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedStaffIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900 mb-1">
|
||||
{order.event_name}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-slate-600">{order.client_business}</p>
|
||||
</div>
|
||||
<Badge
|
||||
className={`${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-500 text-white border-0 animate-pulse'
|
||||
: 'bg-orange-500 text-white border-0'
|
||||
} font-semibold px-3 py-1 shadow-md`}
|
||||
>
|
||||
{totalAssigned >= totalNeeded ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Fully Staffed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Needs Staff
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-slate-700 mt-3">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">
|
||||
{order.event_date ? format(new Date(order.event_date), 'EEEE, MMMM d, yyyy') : 'No date'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentShift.address && (
|
||||
<div className="flex items-center gap-2 text-slate-700 mt-2">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm">{currentShift.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{/* Staff Assignment Summary */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
<h3 className="font-semibold text-slate-900">Staff Assignment</h3>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-sm font-bold border-2 ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'border-emerald-500 text-emerald-700 bg-emerald-50'
|
||||
: 'border-orange-500 text-orange-700 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
{totalAssigned} / {totalNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{totalAssigned >= totalNeeded && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-gradient-to-r from-emerald-50 to-green-50 border-2 border-emerald-200">
|
||||
<div className="flex items-center gap-2 text-emerald-700 text-sm font-semibold">
|
||||
<Check className="w-5 h-5" />
|
||||
✨ Fully staffed - All positions filled!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Position Selection */}
|
||||
{currentShift.roles.length > 1 && (
|
||||
<div className="mb-6">
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">Select Position:</label>
|
||||
<Select
|
||||
value={selectedRoleIndex.toString()}
|
||||
onValueChange={(value) => {
|
||||
setSelectedRoleIndex(parseInt(value));
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currentShift.roles.map((role, idx) => {
|
||||
const roleAssigned = role.assignments?.length || 0;
|
||||
const roleNeeded = parseInt(role.count) || 0;
|
||||
const roleFilled = roleAssigned >= roleNeeded;
|
||||
return (
|
||||
<SelectItem key={idx} value={idx.toString()}>
|
||||
<div className="flex items-center justify-between gap-4 w-full">
|
||||
<span className="font-medium">{role.service}</span>
|
||||
<Badge
|
||||
className={`${
|
||||
roleFilled
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
} text-xs font-bold ml-2`}
|
||||
>
|
||||
{roleAssigned}/{roleNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Position Details */}
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border-2 border-blue-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-bold text-slate-900 text-lg">{currentRole.service}</h4>
|
||||
<Badge
|
||||
className={`${
|
||||
assigned >= needed
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-orange-500 text-white'
|
||||
} font-bold px-3 py-1 text-base`}
|
||||
>
|
||||
{assigned}/{needed}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{currentRole.start_time && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 font-medium">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span>
|
||||
{convertTo12Hour(currentRole.start_time)} - {convertTo12Hour(currentRole.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swap Mode Banner */}
|
||||
{swapMode && (
|
||||
<div className="mb-4 p-4 bg-purple-50 border-2 border-purple-300 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-semibold text-purple-900">
|
||||
Swap Mode Active - Select replacement for {assignments[swapMode.assignmentIndex]?.employee_name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode(null)}
|
||||
className="text-purple-600 hover:bg-purple-100"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned Staff List */}
|
||||
{assignments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-bold text-slate-700 mb-3 uppercase tracking-wide">✅ Assigned Staff:</h4>
|
||||
<div className="space-y-2">
|
||||
{assignments.map((assignment, idx) => {
|
||||
const conflicts = getStaffConflicts(assignment.employee_id);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-xl border-2 border-slate-200 hover:border-blue-300 transition-all shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(assignment.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{assignment.employee_name}</p>
|
||||
<p className="text-xs text-slate-500">{currentRole.service}</p>
|
||||
{conflicts.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">Time conflict detected</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSwapMode({ employeeId: assignment.employee_id, assignmentIndex: idx })}
|
||||
className="text-purple-600 hover:bg-purple-50 border-purple-300"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Swap
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveStaff(assignment.employee_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Staff Section */}
|
||||
{(assigned < needed || swapMode) && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold text-slate-700 uppercase tracking-wide">
|
||||
{swapMode ? '🔄 Select Replacement:' : '➕ Add Staff:'}
|
||||
</h4>
|
||||
{!swapMode && availableStaff.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
disabled={needed - assigned === 0}
|
||||
>
|
||||
Select All ({Math.min(availableStaff.length, needed - assigned)})
|
||||
</Button>
|
||||
{selectedStaffIds.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deselectAll}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkAssign}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign {selectedStaffIds.length}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search staff by name or position..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Showing {availableStaff.length} {currentRole.service.toLowerCase()}(s)
|
||||
{roleFilteredStaff.length !== allStaff.length && (
|
||||
<span className="text-blue-600 font-medium"> (filtered by role)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{availableStaff.length > 0 ? (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto pr-2">
|
||||
{availableStaff.map((staff, idx) => {
|
||||
const isSelected = selectedStaffIds.includes(staff.id);
|
||||
const conflicts = getStaffConflicts(staff.id);
|
||||
const hasConflict = conflicts.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={staff.id}
|
||||
className={`flex items-center justify-between p-3 bg-white rounded-xl border-2 transition-all ${
|
||||
hasConflict
|
||||
? 'border-red-200 bg-red-50'
|
||||
: isSelected
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{!swapMode && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleStaffSelection(staff.id)}
|
||||
disabled={hasConflict}
|
||||
className="border-2"
|
||||
/>
|
||||
)}
|
||||
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
|
||||
<AvatarFallback className="text-white font-bold">
|
||||
{getInitials(staff.employee_name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate">{staff.employee_name}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs text-slate-500">{staff.position || 'Staff Member'}</p>
|
||||
{staff.position_2 && (
|
||||
<Badge variant="outline" className="text-[10px]">{staff.position_2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasConflict && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 font-medium">
|
||||
Conflict: {conflicts[0].orderName} ({conflicts[0].time})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => swapMode ? handleSwapStaff(staff) : handleAssignStaff(staff)}
|
||||
disabled={hasConflict}
|
||||
className={`${
|
||||
swapMode
|
||||
? 'bg-purple-600 hover:bg-purple-700'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
} ${hasConflict ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{swapMode ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Swap
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Assign
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-xl border-2 border-dashed border-slate-200">
|
||||
<Users className="w-16 h-16 mx-auto mb-3 text-slate-300" />
|
||||
<p className="font-medium text-slate-600">
|
||||
{searchTerm
|
||||
? 'No staff match your search'
|
||||
: `No available ${currentRole.service.toLowerCase()}s found`
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{searchTerm ? 'Try a different search term' : 'All matching staff have been assigned'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 flex items-center justify-between bg-slate-50 -mx-6 px-6 -mb-6 pb-6">
|
||||
<div className="flex gap-2">
|
||||
{selectedShiftIndex > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex - 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Previous Shift
|
||||
</Button>
|
||||
)}
|
||||
{selectedShiftIndex < order.shifts_data.length - 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedShiftIndex(selectedShiftIndex + 1);
|
||||
setSelectedRoleIndex(0);
|
||||
setSelectedStaffIds([]);
|
||||
setSwapMode(null);
|
||||
}}
|
||||
>
|
||||
Next Shift
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-4 py-2 text-base font-bold ${
|
||||
totalAssigned >= totalNeeded
|
||||
? 'bg-emerald-50 border-emerald-500 text-emerald-700'
|
||||
: 'bg-orange-50 border-orange-500 text-orange-700'
|
||||
}`}
|
||||
>
|
||||
{totalAssigned}/{totalNeeded} Filled
|
||||
</Badge>
|
||||
<Button onClick={onClose} className="bg-blue-600 hover:bg-blue-700">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, MapPin, Users, FileText } from "lucide-react";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
|
||||
const statusColors = {
|
||||
Draft: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
Active: "bg-emerald-50 text-emerald-700 border border-emerald-200",
|
||||
Pending: "bg-purple-50 text-purple-700 border border-purple-200",
|
||||
Confirmed: "bg-[#0A39DF]/10 text-[#0A39DF] border border-[#0A39DF]/30",
|
||||
Assigned: "bg-amber-50 text-amber-700 border border-amber-200",
|
||||
Completed: "bg-slate-100 text-slate-600 border border-slate-300",
|
||||
Canceled: "bg-red-50 text-red-700 border border-red-200",
|
||||
Cancelled: "bg-red-50 text-red-700 border border-red-200"
|
||||
};
|
||||
|
||||
// Helper function to safely format dates
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
if (!isValid(date)) return "-";
|
||||
return format(date, formatStr);
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventHoverCard({ event, children }) {
|
||||
const assignedCount = event.assigned_staff?.length || event.assigned || 0;
|
||||
const requestedCount = event.requested || 0;
|
||||
const remainingSlots = requestedCount - assignedCount;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96 p-0" side="right" align="start">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-4 border-b border-slate-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-[#1C323E]">{event.event_name}</h3>
|
||||
<p className="text-sm text-slate-600">{event.business_name || "Company Name"}</p>
|
||||
</div>
|
||||
<Badge className={`${statusColors[event.status]} font-medium`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<Calendar className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="font-semibold">{safeFormatDate(event.date, "MMMM dd, yyyy")}</span>
|
||||
</div>
|
||||
|
||||
{event.event_location && (
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<MapPin className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="truncate">{event.event_location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.po && (
|
||||
<div className="flex items-center gap-2 text-slate-700">
|
||||
<FileText className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span>PO: {event.po}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-700">Staff Assignment</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-[#0A39DF] text-[#0A39DF]">
|
||||
{assignedCount} / {requestedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 text-xs text-amber-800">
|
||||
<strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''} still needed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingSlots === 0 && requestedCount > 0 && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-2 text-xs text-emerald-800">
|
||||
✓ Fully staffed
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.assigned_staff && event.assigned_staff.length > 0 && (
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase">Assigned Staff:</p>
|
||||
{event.assigned_staff.map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-6 h-6 bg-[#0A39DF] rounded flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.staff_name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate">{staff.staff_name}</p>
|
||||
{staff.position && <p className="text-xs text-slate-500 truncate">{staff.position}</p>}
|
||||
</div>
|
||||
{staff.confirmed && (
|
||||
<Badge variant="outline" className="text-xs border-emerald-500 text-emerald-700">
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.notes && (
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<p className="text-xs font-semibold text-slate-600 uppercase mb-1">Notes:</p>
|
||||
<p className="text-xs text-slate-600">{event.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, MoreVertical, Users } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
const statusColors = {
|
||||
Active: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
Pending: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
Confirmed: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
Completed: "bg-green-100 text-green-800 border-green-200",
|
||||
Canceled: "bg-red-100 text-red-800 border-red-200"
|
||||
};
|
||||
|
||||
export default function EventsTable({ events }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Hub</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
<TableHead className="font-semibold">Event Name</TableHead>
|
||||
<TableHead className="font-semibold">PO</TableHead>
|
||||
<TableHead className="font-semibold text-center">Requested</TableHead>
|
||||
<TableHead className="font-semibold text-center">Assigned</TableHead>
|
||||
<TableHead className="font-semibold">Total</TableHead>
|
||||
<TableHead className="font-semibold text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.length > 0 ? (
|
||||
events.map((event) => (
|
||||
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors">
|
||||
<TableCell className="font-medium">{event.id?.slice(0, 8)}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{event.hub}</p>
|
||||
{event.event_location && (
|
||||
<p className="text-xs text-slate-500 truncate max-w-xs">{event.event_location}</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${statusColors[event.status]} border font-medium`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{event.event_name}</TableCell>
|
||||
<TableCell>{event.po || "-"}</TableCell>
|
||||
<TableCell className="text-center">{event.requested || 0}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{event.assigned_staff && event.assigned_staff.length > 0 ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-medium">
|
||||
<Users className="w-4 h-4" />
|
||||
{event.assigned_staff.length}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Assigned Staff:</h4>
|
||||
<div className="space-y-2">
|
||||
{event.assigned_staff.map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.staff_name?.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{staff.staff_name}</p>
|
||||
{staff.position && (
|
||||
<p className="text-xs text-slate-500">{staff.position}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span className="text-slate-400">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">${event.total?.toFixed(2) || "0.00"}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-8 text-slate-500">
|
||||
No events found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Users, Search, Plus, CheckCircle2 } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
export default function QuickAssignPopover({ event, children }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedStaff, setSelectedStaff] = useState([]);
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: allStaff } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const requestedCount = event.requested || 0;
|
||||
const currentAssigned = event.assigned_staff || [];
|
||||
const assignedCount = currentAssigned.length;
|
||||
const remainingSlots = requestedCount - assignedCount;
|
||||
|
||||
const handleToggleStaff = (staffId) => {
|
||||
setSelectedStaff(prev => {
|
||||
if (prev.includes(staffId)) {
|
||||
return prev.filter(id => id !== staffId);
|
||||
} else {
|
||||
// Only allow selection up to remaining slots
|
||||
if (remainingSlots > 0 && prev.length >= remainingSlots) {
|
||||
toast({
|
||||
title: "Selection Limit",
|
||||
description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
return [...prev, staffId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedStaff.length === 0) return;
|
||||
|
||||
if (selectedStaff.length > remainingSlots && remainingSlots > 0) {
|
||||
toast({
|
||||
title: "Too Many Selected",
|
||||
description: `Only ${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''} remaining. Assigning first ${remainingSlots}.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
|
||||
const staffToAssign = remainingSlots > 0
|
||||
? selectedStaff.slice(0, remainingSlots)
|
||||
: selectedStaff;
|
||||
|
||||
const newAssignments = staffToAssign.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: true,
|
||||
notified_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignedStaff = [...currentAssigned, ...newAssignments];
|
||||
const newStatus = updatedAssignedStaff.length >= requestedCount ? "Assigned" : "Pending";
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: event.id,
|
||||
data: {
|
||||
...event,
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
assigned: updatedAssignedStaff.length,
|
||||
status: newStatus
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
};
|
||||
|
||||
const availableStaff = allStaff.filter(staff =>
|
||||
!currentAssigned.some(assigned => assigned.staff_id === staff.id)
|
||||
);
|
||||
|
||||
const canSelectMore = remainingSlots === 0 || selectedStaff.length < remainingSlots;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0" align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Quick Assign Staff</h3>
|
||||
<p className="text-xs text-slate-500">{event.event_name}</p>
|
||||
</div>
|
||||
<Badge variant={remainingSlots === 0 ? "default" : "outline"}>
|
||||
{assignedCount} / {requestedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
|
||||
Select up to <strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remainingSlots === 0 && requestedCount > 0 && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-2 text-xs text-green-800 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Fully staffed - no more assignments needed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedStaff.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Assign {Math.min(selectedStaff.length, remainingSlots || selectedStaff.length)} Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Command>
|
||||
<CommandInput placeholder="Search staff..." />
|
||||
<CommandEmpty>No staff found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{availableStaff.map((staff) => {
|
||||
const isSelected = selectedStaff.includes(staff.id);
|
||||
const isDisabled = !canSelectMore && !isSelected && remainingSlots > 0;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={staff.id}
|
||||
onSelect={() => !isDisabled && handleToggleStaff(staff.id)}
|
||||
className={`cursor-pointer ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={() => !isDisabled && handleToggleStaff(staff.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.initial || staff.employee_name?.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate text-sm">
|
||||
{staff.employee_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{staff.position && <span>{staff.position}</span>}
|
||||
{staff.department && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{staff.department}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Calendar as CalendarIcon, Users, Loader2, CheckCircle2, X } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function QuickReorderModal({ event, open, onOpenChange }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedDates, setSelectedDates] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
requested: event?.requested || 1,
|
||||
notes: event?.notes || ""
|
||||
});
|
||||
|
||||
const createEventsMutation = useMutation({
|
||||
mutationFn: async (ordersData) => {
|
||||
// Create multiple orders, one for each date
|
||||
const promises = ordersData.map(orderData =>
|
||||
base44.entities.Event.create(orderData)
|
||||
);
|
||||
return Promise.all(promises);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['client-events'] });
|
||||
toast({
|
||||
title: `${data.length} Order${data.length > 1 ? 's' : ''} Created Successfully! 🎉`,
|
||||
description: `Your order${data.length > 1 ? 's have' : ' has'} been placed and ${data.length > 1 ? 'are' : 'is'} pending confirmation.`,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setSelectedDates([]);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDateSelect = (dates) => {
|
||||
setSelectedDates(dates || []);
|
||||
};
|
||||
|
||||
const handleRemoveDate = (dateToRemove) => {
|
||||
setSelectedDates(selectedDates.filter(d => d.getTime() !== dateToRemove.getTime()));
|
||||
};
|
||||
|
||||
const handleQuickReorder = () => {
|
||||
if (selectedDates.length === 0) {
|
||||
toast({
|
||||
title: "Date Required",
|
||||
description: "Please select at least one date for your order",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an order for each selected date
|
||||
const ordersData = selectedDates.map(date => ({
|
||||
event_name: event.event_name,
|
||||
business_id: event.business_id,
|
||||
business_name: event.business_name,
|
||||
hub: event.hub,
|
||||
event_location: event.event_location,
|
||||
event_type: event.event_type,
|
||||
client_name: event.client_name,
|
||||
client_email: event.client_email,
|
||||
client_phone: event.client_phone,
|
||||
client_address: event.client_address,
|
||||
date: format(date, 'yyyy-MM-dd'),
|
||||
requested: formData.requested,
|
||||
notes: formData.notes,
|
||||
status: "Pending",
|
||||
assigned: 0,
|
||||
assigned_staff: []
|
||||
}));
|
||||
|
||||
createEventsMutation.mutate(ordersData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center shadow-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-center text-2xl text-[#1C323E]">Quick Reorder</DialogTitle>
|
||||
<DialogDescription className="text-center text-base">
|
||||
Reorder "<strong className="text-[#0A39DF]">{event?.event_name}</strong>" - Select multiple dates
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Event Summary */}
|
||||
<div className="bg-gradient-to-br from-[#0A39DF]/5 to-[#1C323E]/5 rounded-lg p-4 border border-[#0A39DF]/20">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Service:</span>
|
||||
<span className="font-semibold text-[#1C323E]">{event?.event_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Location:</span>
|
||||
<span className="font-semibold text-[#1C323E]">{event?.event_location || "Same as before"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar - Multi Date Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
|
||||
<CalendarIcon className="w-5 h-5 text-[#0A39DF]" />
|
||||
Select Event Dates * (Click multiple dates)
|
||||
</Label>
|
||||
<div className="border-2 border-[#0A39DF]/30 rounded-lg p-4 bg-white">
|
||||
<Calendar
|
||||
mode="multiple"
|
||||
selected={selectedDates}
|
||||
onSelect={handleDateSelect}
|
||||
className="rounded-md"
|
||||
disabled={(date) => date < new Date()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selected Dates Display */}
|
||||
{selectedDates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-[#1C323E]">
|
||||
Selected Dates ({selectedDates.length})
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedDates([])}
|
||||
className="text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-50 rounded-lg border border-slate-200">
|
||||
{selectedDates.sort((a, b) => a - b).map((date, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
className="bg-[#0A39DF] text-white px-3 py-1.5 flex items-center gap-2 hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
{format(date, 'MMM d, yyyy')}
|
||||
<button
|
||||
onClick={() => handleRemoveDate(date)}
|
||||
className="hover:bg-white/20 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Staff Count */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="requested" className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
|
||||
<Users className="w-4 h-4 text-[#0A39DF]" />
|
||||
Number of Staff Needed
|
||||
</Label>
|
||||
<Input
|
||||
id="requested"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.requested}
|
||||
onChange={(e) => setFormData({ ...formData, requested: parseInt(e.target.value) || 1 })}
|
||||
className="text-lg font-semibold border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-base font-semibold text-[#1C323E]">
|
||||
Special Instructions (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Any changes or special requests..."
|
||||
rows={3}
|
||||
className="border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{selectedDates.length > 0 && (
|
||||
<div className="bg-gradient-to-r from-[#0A39DF]/10 to-[#1C323E]/10 rounded-lg p-4 border-2 border-[#0A39DF]/30">
|
||||
<p className="text-sm font-semibold text-[#1C323E] mb-2">Order Summary:</p>
|
||||
<ul className="text-sm text-slate-700 space-y-1">
|
||||
<li>• <strong>{selectedDates.length}</strong> order{selectedDates.length > 1 ? 's' : ''} will be created</li>
|
||||
<li>• <strong>{formData.requested}</strong> staff per event</li>
|
||||
<li>• Total: <strong className="text-[#0A39DF]">{selectedDates.length * formData.requested}</strong> staff across all dates</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={createEventsMutation.isPending}
|
||||
className="flex-1 border-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleQuickReorder}
|
||||
disabled={createEventsMutation.isPending || selectedDates.length === 0}
|
||||
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
|
||||
>
|
||||
{createEventsMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating {selectedDates.length} Order{selectedDates.length > 1 ? 's' : ''}...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Place {selectedDates.length || 0} Order{selectedDates.length !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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 { Clock, MapPin, Users, DollarSign, UserPlus } from "lucide-react";
|
||||
import SmartAssignModal from "./SmartAssignModal";
|
||||
import AssignedStaffManager from "./AssignedStaffManager";
|
||||
|
||||
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, currentUser }) {
|
||||
const [assignModal, setAssignModal] = useState({ open: false, role: null });
|
||||
|
||||
const roles = shift?.roles || [];
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-white border-2 border-slate-200 shadow-sm">
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-bold text-slate-900">
|
||||
{shift.shift_name || "Shift"}
|
||||
</CardTitle>
|
||||
{shift.location && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 mt-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{shift.location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="bg-[#0A39DF] text-white font-semibold px-3 py-1.5">
|
||||
{roles.length} Role{roles.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-2 border-slate-200 rounded-xl p-4 hover:shadow-sm transition-shadow bg-white"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-bold text-slate-900 text-lg">{role.role}</h4>
|
||||
<Badge className={`${statusColor} border-2 font-bold px-3 py-1`}>
|
||||
{assignedCount} / {requiredCount} Assigned
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
{role.start_time && role.end_time && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||
</span>
|
||||
)}
|
||||
{role.department && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300">
|
||||
{role.department}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAssignStaff && remainingCount > 0 && (
|
||||
<Button
|
||||
onClick={() => setAssignModal({ open: true, role })}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 gap-2 font-semibold"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
Assign Staff ({remainingCount} needed)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show assigned staff */}
|
||||
{assignedCount > 0 && (
|
||||
<div className="border-t border-slate-200 pt-4 mt-4">
|
||||
<p className="text-xs font-bold text-slate-700 mb-3 uppercase tracking-wide">
|
||||
Assigned Staff
|
||||
</p>
|
||||
<AssignedStaffManager event={event} shift={shift} role={role} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional role details */}
|
||||
{(role.uniform || role.cost_per_hour) && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-slate-200">
|
||||
{role.uniform && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Uniform</p>
|
||||
<p className="text-sm font-medium text-slate-900">{role.uniform}</p>
|
||||
</div>
|
||||
)}
|
||||
{role.cost_per_hour && (
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-[#0A39DF]" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Rate</p>
|
||||
<p className="text-sm font-bold text-slate-900">${role.cost_per_hour}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Smart Assignment Modal */}
|
||||
<SmartAssignModal
|
||||
open={assignModal.open}
|
||||
onClose={() => setAssignModal({ open: false, role: null })}
|
||||
event={event}
|
||||
shift={shift}
|
||||
role={assignModal.role}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
|
||||
import React, { useMemo, 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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Minus, Trash2, Search, DollarSign, TrendingUp, Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEPARTMENTS = [
|
||||
"Accounting", "Operations", "Sales", "HR", "Finance",
|
||||
"IT", "Marketing", "Customer Service", "Logistics"
|
||||
];
|
||||
|
||||
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
|
||||
|
||||
const TIME_OPTIONS = [];
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
|
||||
}
|
||||
}
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ShiftRoleCard({ role, roleIndex, onRoleChange, onDelete, canDelete, selectedVendor }) {
|
||||
const [roleSearchOpen, setRoleSearchOpen] = useState(false);
|
||||
|
||||
// Get current user to check role
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-shift-role'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role;
|
||||
const isClient = userRole === "client";
|
||||
|
||||
// Get client's vendor relationships if client
|
||||
const { data: clientVendors = [] } = useQuery({
|
||||
queryKey: ['client-vendors-for-roles', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!isClient) return [];
|
||||
|
||||
const allEvents = await base44.entities.Event.list();
|
||||
const clientEvents = allEvents.filter(e =>
|
||||
e.client_email === user?.email ||
|
||||
e.business_name === user?.company_name ||
|
||||
e.created_by === user?.email
|
||||
);
|
||||
|
||||
const vendorNames = new Set();
|
||||
clientEvents.forEach(event => {
|
||||
if (event.shifts && Array.isArray(event.shifts)) {
|
||||
event.shifts.forEach(shift => {
|
||||
if (shift.roles && Array.isArray(shift.roles)) {
|
||||
shift.roles.forEach(role => {
|
||||
if (role.vendor_name) vendorNames.add(role.vendor_name);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(vendorNames);
|
||||
},
|
||||
enabled: isClient && !!user,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Fetch all vendor rates
|
||||
const { data: allRates = [], isLoading } = useQuery({
|
||||
queryKey: ['vendor-rates-for-event'],
|
||||
queryFn: () => base44.entities.VendorRate.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
// Filter rates by selected vendor AND client access
|
||||
const availableRates = useMemo(() => {
|
||||
if (!allRates || !Array.isArray(allRates)) return [];
|
||||
|
||||
let filtered = allRates.filter(r => r && r.is_active);
|
||||
|
||||
// If client, only show rates from vendors they work with
|
||||
if (isClient) {
|
||||
filtered = filtered.filter(r => {
|
||||
const hasVendorRelationship = clientVendors.length === 0 || clientVendors.includes(r.vendor_name);
|
||||
const isVisibleToClient =
|
||||
r.client_visibility === 'all' ||
|
||||
(r.client_visibility === 'specific' &&
|
||||
r.available_to_clients &&
|
||||
Array.isArray(r.available_to_clients) && // Ensure it's an array before using includes
|
||||
r.available_to_clients.includes(user?.id));
|
||||
|
||||
return hasVendorRelationship && isVisibleToClient;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by selected vendor if provided
|
||||
if (selectedVendor) {
|
||||
filtered = filtered.filter(r => r.vendor_name === selectedVendor);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [allRates, selectedVendor, isClient, clientVendors, user?.id]);
|
||||
|
||||
// Group rates by category
|
||||
const ratesByCategory = useMemo(() => {
|
||||
if (!availableRates || !Array.isArray(availableRates)) return {};
|
||||
return availableRates.reduce((acc, rate) => {
|
||||
if (!rate || !rate.category) return acc;
|
||||
if (!acc[rate.category]) acc[rate.category] = [];
|
||||
acc[rate.category].push(rate);
|
||||
return acc;
|
||||
}, {});
|
||||
}, [availableRates]);
|
||||
|
||||
// Handle role selection from vendor rates
|
||||
const handleRoleSelect = (rate) => {
|
||||
if (!rate) return;
|
||||
onRoleChange('role', rate.role_name || '');
|
||||
onRoleChange('department', rate.category || '');
|
||||
onRoleChange('cost_per_hour', rate.client_rate || 0);
|
||||
onRoleChange('vendor_name', rate.vendor_name || '');
|
||||
onRoleChange('vendor_id', rate.vendor_id || '');
|
||||
setRoleSearchOpen(false);
|
||||
};
|
||||
|
||||
// Get selected rate details
|
||||
const selectedRate = availableRates.find(r => r && r.role_name === role.role);
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
{/* Row Number */}
|
||||
<div className="col-span-12 md:col-span-1 flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-slate-700 rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{roleIndex + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Selection with Vendor Rates - SEARCHABLE */}
|
||||
<div className="col-span-12 md:col-span-3 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Service / Role</Label>
|
||||
<Popover open={roleSearchOpen} onOpenChange={setRoleSearchOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between text-sm h-auto py-2 bg-white hover:bg-slate-50",
|
||||
!role.role && "text-slate-500"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 text-left">
|
||||
{role.role ? (
|
||||
<>
|
||||
<span className="font-semibold text-slate-900">{role.role}</span>
|
||||
{selectedRate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px] bg-blue-50 text-blue-700 border-blue-200">
|
||||
{selectedRate.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-green-600 font-bold">${selectedRate.client_rate}/hr</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Select service...</span>
|
||||
)}
|
||||
</div>
|
||||
<Search className="w-4 h-4 ml-2 text-slate-400 flex-shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search services..." className="h-9" />
|
||||
<CommandEmpty>
|
||||
<div className="p-4 text-center text-sm text-slate-500">
|
||||
<p>No services found.</p>
|
||||
{selectedVendor && (
|
||||
<p className="mt-2 text-xs">Contact {selectedVendor} to add services.</p>
|
||||
)}
|
||||
{!selectedVendor && (
|
||||
<p className="mt-2 text-xs">Select a vendor first to see available services.</p>
|
||||
)}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{Object.entries(ratesByCategory || {}).map(([category, rates]) => (
|
||||
<CommandGroup key={category} heading={category} className="text-slate-700">
|
||||
{Array.isArray(rates) && rates.map((rate) => (
|
||||
<CommandItem
|
||||
key={rate.id}
|
||||
value={`${rate.role_name} ${rate.vendor_name} ${rate.category}`}
|
||||
onSelect={() => handleRoleSelect(rate)}
|
||||
className="flex items-center justify-between py-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-sm text-slate-900">{rate.role_name}</p>
|
||||
{role.role === rate.role_name && (
|
||||
<Check className="w-4 h-4 text-[#0A39DF]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-[10px] bg-slate-50">
|
||||
{rate.vendor_name}
|
||||
</Badge>
|
||||
{rate.pricing_status === 'optimal' && (
|
||||
<Badge className="bg-green-100 text-green-700 text-[10px] border-0">
|
||||
Optimal Price
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-[#0A39DF]">${rate.client_rate}</p>
|
||||
<p className="text-[10px] text-slate-500">per hour</p>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Department (Auto-filled from category) */}
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Department</Label>
|
||||
<Select value={role.department || ""} onValueChange={(value) => onRoleChange('department', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENTS.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className="col-span-6 md:col-span-2">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Count</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 bg-white hover:bg-slate-50"
|
||||
onClick={() => onRoleChange('count', Math.max(1, (role.count || 1) - 1))}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.count || 1}
|
||||
onChange={(e) => onRoleChange('count', parseInt(e.target.value) || 1)}
|
||||
className="w-14 h-9 text-center p-0 text-sm bg-white"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 bg-white hover:bg-slate-50"
|
||||
onClick={() => onRoleChange('count', (role.count || 1) + 1)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="col-span-6 md:col-span-3 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Start Time</Label>
|
||||
<Select value={role.start_time || "12:00 PM"} onValueChange={(value) => onRoleChange('start_time', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">End Time</Label>
|
||||
<Select value={role.end_time || "05:00 PM"} onValueChange={(value) => onRoleChange('end_time', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-48">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hours Badge */}
|
||||
<div className="col-span-3 md:col-span-1 flex flex-col items-center justify-center">
|
||||
<Label className="text-xs text-slate-600 mb-1">Hours</Label>
|
||||
<div className="bg-[#0A39DF] text-white rounded-full w-10 h-10 flex items-center justify-center font-bold text-sm">
|
||||
{role.hours || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uniform & Break */}
|
||||
<div className="col-span-9 md:col-span-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Uniform</Label>
|
||||
<Select value={role.uniform || "Type 1"} onValueChange={(value) => onRoleChange('uniform', value)}>
|
||||
<SelectTrigger className="h-9 text-sm bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIFORMS.map(u => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-600 mb-1">Break (min)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.break_minutes || 30}
|
||||
onChange={(e) => onRoleChange('break_minutes', parseInt(e.target.value) || 0)}
|
||||
className="h-9 text-center text-sm bg-white"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost & Value */}
|
||||
<div className="col-span-12 md:col-span-3 flex items-end justify-between gap-4 pt-4 md:pt-0 border-t md:border-t-0 border-slate-200">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Rate/hr</Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
|
||||
<Input
|
||||
type="number"
|
||||
value={role.cost_per_hour || 0}
|
||||
onChange={(e) => onRoleChange('cost_per_hour', parseFloat(e.target.value) || 0)}
|
||||
className="h-9 text-sm pl-6 bg-white"
|
||||
placeholder="0.00"
|
||||
disabled={!!selectedRate}
|
||||
/>
|
||||
</div>
|
||||
{selectedRate && (
|
||||
<p className="text-[10px] text-green-600 mt-1">From vendor rate card</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-600 mb-1 block">Total</Label>
|
||||
<div className="h-9 flex items-center justify-end font-bold text-[#0A39DF] text-lg">
|
||||
${(role.total_value || 0).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Info Bar */}
|
||||
{selectedRate && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200 flex items-center justify-between text-xs bg-slate-50 -mx-4 -mb-4 px-4 py-3 rounded-b-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-slate-500">
|
||||
Employee Wage: <span className="font-semibold text-slate-700">${selectedRate.employee_wage || 0}/hr</span>
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
Markup: <span className="font-semibold text-blue-600">{selectedRate.markup_percentage || 0}%</span>
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
VA Fee: <span className="font-semibold text-purple-600">{selectedRate.vendor_fee_percentage || 0}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-700 border-green-200">
|
||||
Transparent Pricing
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
import React from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Minus, Pencil, Trash2, Search } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
const ROLES = [
|
||||
"Front Desk",
|
||||
"Finance",
|
||||
"Hospitality",
|
||||
"Recruiter",
|
||||
"Server",
|
||||
"Bartender",
|
||||
"Cook",
|
||||
"Dishwasher",
|
||||
"Security",
|
||||
"Janitor"
|
||||
];
|
||||
|
||||
const DEPARTMENTS = [
|
||||
"Accounting",
|
||||
"Operations",
|
||||
"Sales",
|
||||
"HR",
|
||||
"Finance",
|
||||
"IT",
|
||||
"Marketing",
|
||||
"Customer Service",
|
||||
"Logistics"
|
||||
];
|
||||
|
||||
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
|
||||
|
||||
const TIME_OPTIONS = [];
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
|
||||
}
|
||||
}
|
||||
for (let h = 1; h <= 12; h++) {
|
||||
for (let m of ['00', '30']) {
|
||||
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ShiftRolesTable({ roles, onChange }) {
|
||||
const handleAddRole = () => {
|
||||
onChange([...roles, {
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "12:00 PM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleDeleteRole = (index) => {
|
||||
onChange(roles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRoleChange = (index, field, value) => {
|
||||
const newRoles = [...roles];
|
||||
newRoles[index] = { ...newRoles[index], [field]: value };
|
||||
|
||||
// Calculate hours if times changed
|
||||
if (field === 'start_time' || field === 'end_time') {
|
||||
const start = newRoles[index].start_time;
|
||||
const end = newRoles[index].end_time;
|
||||
const hours = calculateHours(start, end);
|
||||
newRoles[index].hours = hours;
|
||||
}
|
||||
|
||||
// Calculate total value
|
||||
const count = newRoles[index].count || 0;
|
||||
const hours = newRoles[index].hours || 0;
|
||||
const cost = newRoles[index].cost_per_hour || 0;
|
||||
newRoles[index].total_value = count * hours * cost;
|
||||
|
||||
onChange(newRoles);
|
||||
};
|
||||
|
||||
const calculateHours = (start, end) => {
|
||||
// Simple calculation - in production, use proper time library
|
||||
const startHour = parseInt(start.split(':')[0]) + (start.includes('PM') && !start.startsWith('12') ? 12 : 0);
|
||||
const endHour = parseInt(end.split(':')[0]) + (end.includes('PM') && !end.startsWith('12') ? 12 : 0);
|
||||
return Math.max(0, endHour - startHour);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead className="w-12 text-center">#</TableHead>
|
||||
<TableHead className="min-w-[150px]">Role</TableHead>
|
||||
<TableHead className="min-w-[130px]">Department</TableHead>
|
||||
<TableHead className="w-24 text-center">Count</TableHead>
|
||||
<TableHead className="min-w-[120px]">Start Date</TableHead>
|
||||
<TableHead className="min-w-[120px]">End Date</TableHead>
|
||||
<TableHead className="w-20 text-center">Hours</TableHead>
|
||||
<TableHead className="min-w-[100px]">Uniform</TableHead>
|
||||
<TableHead className="w-24">Break</TableHead>
|
||||
<TableHead className="w-20">Cost</TableHead>
|
||||
<TableHead className="w-28 text-right">Value</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role, index) => (
|
||||
<TableRow key={index} className="hover:bg-slate-50">
|
||||
<TableCell className="text-center font-medium text-slate-600">{index + 1}</TableCell>
|
||||
|
||||
{/* Role */}
|
||||
<TableCell>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
{role.role || "Role"}
|
||||
<Search className="w-4 h-4 ml-2 text-slate-400" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search role..." />
|
||||
<CommandEmpty>No role found.</CommandEmpty>
|
||||
<CommandGroup className="max-h-64 overflow-auto">
|
||||
{ROLES.map((r) => (
|
||||
<CommandItem
|
||||
key={r}
|
||||
onSelect={() => handleRoleChange(index, 'role', r)}
|
||||
>
|
||||
{r}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</TableCell>
|
||||
|
||||
{/* Department */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.department}
|
||||
onValueChange={(value) => handleRoleChange(index, 'department', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEPARTMENTS.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Count */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRoleChange(index, 'count', Math.max(1, role.count - 1))}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.count}
|
||||
onChange={(e) => handleRoleChange(index, 'count', parseInt(e.target.value) || 1)}
|
||||
className="w-12 h-8 text-center p-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRoleChange(index, 'count', role.count + 1)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Start Time */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.start_time}
|
||||
onValueChange={(value) => handleRoleChange(index, 'start_time', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* End Time */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.end_time}
|
||||
onValueChange={(value) => handleRoleChange(index, 'end_time', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{TIME_OPTIONS.map(time => (
|
||||
<SelectItem key={time} value={time}>{time}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Hours */}
|
||||
<TableCell className="text-center font-semibold">{role.hours}</TableCell>
|
||||
|
||||
{/* Uniform */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={role.uniform}
|
||||
onValueChange={(value) => handleRoleChange(index, 'uniform', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIFORMS.map(u => (
|
||||
<SelectItem key={u} value={u}>{u}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
|
||||
{/* Break */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.break_minutes}
|
||||
onChange={(e) => handleRoleChange(index, 'break_minutes', parseInt(e.target.value) || 0)}
|
||||
className="w-20 text-center"
|
||||
placeholder="30"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Cost */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={role.cost_per_hour}
|
||||
onChange={(e) => handleRoleChange(index, 'cost_per_hour', parseFloat(e.target.value) || 0)}
|
||||
className="w-20 text-center"
|
||||
placeholder="45"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Value */}
|
||||
<TableCell className="text-right font-bold text-[#0A39DF]">
|
||||
${role.total_value?.toFixed(2) || '0.00'}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="w-4 h-4 text-slate-600" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleDeleteRole(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Add Role Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddRole}
|
||||
className="border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF]"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Plus, Trash2, Users, MapPin } from "lucide-react";
|
||||
import ShiftRoleCard from "./ShiftRoleCard";
|
||||
|
||||
export default function ShiftSection({ shifts = [], onChange, addons = {}, onAddonsChange, selectedVendor }) {
|
||||
const handleAddShift = () => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
onChange([...currentShifts, {
|
||||
shift_name: `Shift ${currentShifts.length + 1}`,
|
||||
shift_contact: "",
|
||||
location_address: "",
|
||||
roles: [{
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "12:00 PM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}]
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleDeleteShift = (index) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
onChange(currentShifts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleShiftChange = (index, field, value) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const newShifts = [...currentShifts];
|
||||
newShifts[index] = { ...newShifts[index], [field]: value };
|
||||
onChange(newShifts);
|
||||
};
|
||||
|
||||
const handleRolesChange = (shiftIndex, roles) => {
|
||||
const currentShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const newShifts = [...currentShifts];
|
||||
newShifts[shiftIndex] = { ...newShifts[shiftIndex], roles };
|
||||
onChange(newShifts);
|
||||
};
|
||||
|
||||
const handleAddonToggle = (addon, checked) => {
|
||||
const currentAddons = addons || {};
|
||||
onAddonsChange({
|
||||
...currentAddons,
|
||||
[addon]: typeof currentAddons[addon] === 'object'
|
||||
? { ...currentAddons[addon], enabled: checked }
|
||||
: checked
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddonTextChange = (addon, text) => {
|
||||
const currentAddons = addons || {};
|
||||
onAddonsChange({
|
||||
...currentAddons,
|
||||
[addon]: { ...currentAddons[addon], text }
|
||||
});
|
||||
};
|
||||
|
||||
const safeShifts = Array.isArray(shifts) ? shifts : [];
|
||||
const safeAddons = addons || {
|
||||
goal: { enabled: false, text: "" },
|
||||
portal_access: false,
|
||||
meal_provided: false,
|
||||
travel_time: false,
|
||||
tips: { enabled: false, amount: "300/300" }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{safeShifts.map((shift, shiftIndex) => {
|
||||
const safeShift = shift || {};
|
||||
const safeRoles = Array.isArray(safeShift.roles) ? safeShift.roles : [];
|
||||
|
||||
return (
|
||||
<Card key={shiftIndex} className="border-2 border-slate-200 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
{/* Shift Header */}
|
||||
<div className="flex items-start justify-between mb-6 pb-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg">
|
||||
{shiftIndex + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={safeShift.shift_name || ""}
|
||||
onChange={(e) => handleShiftChange(shiftIndex, 'shift_name', e.target.value)}
|
||||
className="font-bold text-lg border-none p-0 h-auto focus-visible:ring-0 mb-2"
|
||||
placeholder="Shift Name"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<MapPin className="w-4 h-4 flex-shrink-0" />
|
||||
<Input
|
||||
value={safeShift.location_address || ""}
|
||||
onChange={(e) => handleShiftChange(shiftIndex, 'location_address', e.target.value)}
|
||||
placeholder="1234 Elm Street, Apt 56B, Springfield, IL 62704, USA"
|
||||
className="border-none p-0 h-auto text-sm focus-visible:ring-0 text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-300"
|
||||
>
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
Add shift contact
|
||||
</Button>
|
||||
{safeShifts.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteShift(shiftIndex)}
|
||||
className="border-red-300 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Shift
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles as Cards */}
|
||||
<div className="space-y-4">
|
||||
{safeRoles.map((role, roleIndex) => (
|
||||
<ShiftRoleCard
|
||||
key={roleIndex}
|
||||
role={role || {}}
|
||||
roleIndex={roleIndex}
|
||||
selectedVendor={selectedVendor}
|
||||
onRoleChange={(field, value) => {
|
||||
const newRoles = [...safeRoles];
|
||||
newRoles[roleIndex] = { ...newRoles[roleIndex], [field]: value };
|
||||
|
||||
// Calculate hours if times changed
|
||||
if (field === 'start_time' || field === 'end_time') {
|
||||
const start = newRoles[roleIndex].start_time;
|
||||
const end = newRoles[roleIndex].end_time;
|
||||
const hours = calculateHours(start, end);
|
||||
newRoles[roleIndex].hours = hours;
|
||||
}
|
||||
|
||||
// Calculate total value
|
||||
const count = newRoles[roleIndex].count || 0;
|
||||
const hours = newRoles[roleIndex].hours || 0;
|
||||
const cost = newRoles[roleIndex].cost_per_hour || 0;
|
||||
newRoles[roleIndex].total_value = count * hours * cost;
|
||||
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newRoles = safeRoles.filter((_, i) => i !== roleIndex);
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
canDelete={safeRoles.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Role Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newRoles = [...safeRoles, {
|
||||
role: "",
|
||||
department: "",
|
||||
count: 1,
|
||||
start_time: "12:00 PM",
|
||||
end_time: "05:00 PM",
|
||||
hours: 5,
|
||||
uniform: "Type 1",
|
||||
break_minutes: 30,
|
||||
cost_per_hour: 45,
|
||||
total_value: 0
|
||||
}];
|
||||
handleRolesChange(shiftIndex, newRoles);
|
||||
}}
|
||||
className="w-full mt-4 border-dashed border-2 border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Role
|
||||
</Button>
|
||||
|
||||
{/* Shift Total */}
|
||||
<div className="mt-6 pt-4 border-t border-slate-200 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-slate-600">Shift Total:</span>
|
||||
<span className="text-2xl font-bold text-[#0A39DF]">
|
||||
${safeRoles.reduce((sum, r) => sum + ((r && r.total_value) || 0), 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Location/Shift Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddShift}
|
||||
className="w-full h-16 border-2 border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50 text-base font-semibold"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Add Another Location / Shift
|
||||
</Button>
|
||||
|
||||
{/* Other Addons */}
|
||||
<Card className="border-slate-200 shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-6">Other Addons</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Goal */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Goal</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={safeAddons.goal?.enabled || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('goal', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
{safeAddons.goal?.enabled && (
|
||||
<Input
|
||||
value={safeAddons.goal?.text || ""}
|
||||
onChange={(e) => handleAddonTextChange('goal', e.target.value)}
|
||||
placeholder="Enter goal"
|
||||
className="w-64"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Portal Access */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Portal Access</Label>
|
||||
<Switch
|
||||
checked={safeAddons.portal_access || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('portal_access', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meal Provided */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Meal Provided</Label>
|
||||
<Switch
|
||||
checked={safeAddons.meal_provided || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('meal_provided', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Travel Time */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Travel Time</Label>
|
||||
<Switch
|
||||
checked={safeAddons.travel_time || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('travel_time', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm text-slate-700">Tips</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={safeAddons.tips?.enabled || false}
|
||||
onCheckedChange={(checked) => handleAddonToggle('tips', checked)}
|
||||
className="data-[state=checked]:bg-green-500"
|
||||
/>
|
||||
{safeAddons.tips?.enabled && (
|
||||
<Input
|
||||
value={safeAddons.tips?.amount || ""}
|
||||
onChange={(e) => handleAddonTextChange('tips', e.target.value)}
|
||||
placeholder="300/300"
|
||||
className="w-24 text-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div>
|
||||
<Label className="text-sm text-slate-700 mb-2 block">Comments</Label>
|
||||
<Textarea
|
||||
placeholder="Add your main text here..."
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function calculateHours(start, end) {
|
||||
if (!start || !end) return 0;
|
||||
const startHour = parseInt(start.split(':')[0]) + (start.includes('PM') && !start.startsWith('12') ? 12 : 0);
|
||||
const endHour = parseInt(end.split(':')[0]) + (end.includes('PM') && !end.startsWith('12') ? 12 : 0);
|
||||
return Math.max(0, endHour - startHour);
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
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";
|
||||
import { calculateOrderStatus } from "../orders/OrderStatusUtils";
|
||||
import { calculateOTStatus, getOTBadgeProps } from "../scheduling/OvertimeCalculator";
|
||||
|
||||
// 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 [assignments, setAssignments] = useState({}); // { roleKey: Set([staffIds]) }
|
||||
const [sortMode, setSortMode] = useState("smart");
|
||||
const [otWarning, setOtWarning] = useState(null); // OT warning dialog state
|
||||
|
||||
// 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) {
|
||||
setAssignments({});
|
||||
setSearchQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
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
|
||||
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,
|
||||
key: `${s.shift_name}-${r.role}`,
|
||||
label: `${r.role}`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return roles;
|
||||
}, [event]);
|
||||
|
||||
const totalNeeded = allRoles.reduce((sum, r) => sum + r.remaining, 0);
|
||||
const totalSelected = Object.values(assignments).reduce((sum, set) => sum + set.size, 0);
|
||||
|
||||
// Get all staff with their matching roles
|
||||
const staffByRole = useMemo(() => {
|
||||
if (!event || allRoles.length === 0) return {};
|
||||
|
||||
const roleMap = {};
|
||||
|
||||
allRoles.forEach(roleItem => {
|
||||
const matchingStaff = allStaff
|
||||
.filter(staff => {
|
||||
const positionMatch = staff.position === roleItem.role.role ||
|
||||
staff.position_2 === roleItem.role.role;
|
||||
|
||||
if (!positionMatch) return false;
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return staff.employee_name?.toLowerCase().includes(query) ||
|
||||
staff.hub_location?.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(staff => {
|
||||
const conflicts = allEvents.filter(e => {
|
||||
if (e.id === event.id || e.status === "Canceled" || e.status === "Completed") return false;
|
||||
const isAssigned = e.assigned_staff?.some(s => s.staff_id === staff.id);
|
||||
if (!isAssigned) return false;
|
||||
|
||||
return (e.shifts || []).some(eventShift => {
|
||||
return (eventShift.roles || []).some(eventRole => {
|
||||
const isStaffInRole = e.assigned_staff?.some(
|
||||
s => s.staff_id === staff.id && s.role === eventRole.role
|
||||
);
|
||||
if (!isStaffInRole) 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${roleItem.role.start_time || '00:00'}`;
|
||||
const currentEnd = `${event.date}T${roleItem.role.end_time || '23:59'}`;
|
||||
|
||||
return hasTimeOverlap(shiftStart, shiftEnd, currentStart, currentEnd);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const hasConflict = conflicts.length > 0;
|
||||
const reliability = staff.reliability_score || 85;
|
||||
|
||||
// Calculate OT status
|
||||
const otAnalysis = calculateOTStatus(
|
||||
{ ...staff, state: event.state || 'CA' },
|
||||
{
|
||||
...roleItem.role,
|
||||
date: event.date,
|
||||
state: event.state || 'CA'
|
||||
},
|
||||
allEvents
|
||||
);
|
||||
|
||||
const smartScore = reliability - (hasConflict ? 50 : 0) - (otAnalysis.status === 'RED' ? 30 : otAnalysis.status === 'AMBER' ? 10 : 0);
|
||||
|
||||
return { ...staff, hasConflict, reliability, smartScore, otAnalysis };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.hasConflict !== b.hasConflict) return a.hasConflict ? 1 : -1;
|
||||
return b.smartScore - a.smartScore;
|
||||
});
|
||||
|
||||
roleMap[roleItem.key] = matchingStaff;
|
||||
});
|
||||
|
||||
return roleMap;
|
||||
}, [allStaff, allEvents, allRoles, event, searchQuery]);
|
||||
|
||||
const handleAutoAssignAll = () => {
|
||||
const newAssignments = {};
|
||||
|
||||
allRoles.forEach(roleItem => {
|
||||
const available = (staffByRole[roleItem.key] || []).filter(s => !s.hasConflict);
|
||||
const best = available.slice(0, roleItem.remaining);
|
||||
newAssignments[roleItem.key] = new Set(best.map(s => s.id));
|
||||
});
|
||||
|
||||
setAssignments(newAssignments);
|
||||
};
|
||||
|
||||
const toggleStaffForRole = (roleKey, staffId, maxCount, staffMember) => {
|
||||
// Check for OT warning
|
||||
if (staffMember?.otAnalysis?.requiresApproval && !assignments[roleKey]?.has(staffId)) {
|
||||
setOtWarning({
|
||||
staff: staffMember,
|
||||
roleKey,
|
||||
maxCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAssignments(prev => {
|
||||
const current = prev[roleKey] || new Set();
|
||||
const newSet = new Set(current);
|
||||
|
||||
if (newSet.has(staffId)) {
|
||||
newSet.delete(staffId);
|
||||
} else {
|
||||
if (newSet.size >= maxCount) {
|
||||
toast({
|
||||
title: "Limit Reached",
|
||||
description: `Only ${maxCount} staff needed for this role`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return prev;
|
||||
}
|
||||
newSet.add(staffId);
|
||||
}
|
||||
|
||||
return { ...prev, [roleKey]: newSet };
|
||||
});
|
||||
};
|
||||
|
||||
const confirmOTAssignment = () => {
|
||||
if (!otWarning) return;
|
||||
|
||||
const { roleKey, staff, maxCount } = otWarning;
|
||||
setAssignments(prev => {
|
||||
const current = prev[roleKey] || new Set();
|
||||
const newSet = new Set(current);
|
||||
|
||||
if (newSet.size < maxCount) {
|
||||
newSet.add(staff.id);
|
||||
}
|
||||
|
||||
return { ...prev, [roleKey]: newSet };
|
||||
});
|
||||
|
||||
setOtWarning(null);
|
||||
};
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const allNewStaff = [];
|
||||
|
||||
// Collect all assignments
|
||||
allRoles.forEach(roleItem => {
|
||||
const selectedIds = assignments[roleItem.key] || new Set();
|
||||
const staff = (staffByRole[roleItem.key] || []).filter(s => selectedIds.has(s.id));
|
||||
|
||||
staff.forEach(s => {
|
||||
allNewStaff.push({
|
||||
staff_id: s.id,
|
||||
staff_name: s.employee_name,
|
||||
email: s.email,
|
||||
role: roleItem.role.role,
|
||||
department: roleItem.role.department,
|
||||
shift_name: roleItem.shift.shift_name,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const updatedAssignedStaff = [...(event.assigned_staff || []), ...allNewStaff];
|
||||
|
||||
const updatedShifts = (event.shifts || []).map(shift => {
|
||||
const updatedRoles = (shift.roles || []).map(role => {
|
||||
const roleKey = `${shift.shift_name}-${role.role}`;
|
||||
const selectedCount = (assignments[roleKey] || new Set()).size;
|
||||
|
||||
if (selectedCount > 0) {
|
||||
return {
|
||||
...role,
|
||||
assigned: (role.assigned || 0) + selectedCount,
|
||||
};
|
||||
}
|
||||
return role;
|
||||
});
|
||||
return { ...shift, roles: updatedRoles };
|
||||
});
|
||||
|
||||
const updatedEvent = {
|
||||
assigned_staff: updatedAssignedStaff,
|
||||
shifts: updatedShifts,
|
||||
};
|
||||
|
||||
updatedEvent.status = calculateOrderStatus({
|
||||
...event,
|
||||
...updatedEvent
|
||||
});
|
||||
|
||||
await base44.entities.Event.update(event.id, updatedEvent);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
toast({
|
||||
title: "✅ Staff Assigned",
|
||||
description: `Successfully assigned ${totalSelected} staff members`,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "❌ Assignment Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAssign = () => {
|
||||
if (totalSelected === 0) {
|
||||
toast({
|
||||
title: "No Selection",
|
||||
description: "Please select staff members to assign",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
assignMutation.mutate();
|
||||
};
|
||||
|
||||
if (!event || allRoles.length === 0) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>No Roles to Assign</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-slate-600">All positions are fully staffed.</p>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-[#0A39DF]" />
|
||||
Assign Staff
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-3 mt-2 text-sm text-slate-600">
|
||||
<span>{event.event_name}</span>
|
||||
<span>•</span>
|
||||
<span>{event.date ? format(new Date(event.date), 'MMM d, yyyy') : 'Date TBD'}</span>
|
||||
<span>•</span>
|
||||
<span className="font-semibold text-[#0A39DF]">{totalNeeded} positions to fill</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleAutoAssignAll}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-blue-600 hover:from-blue-700 hover:to-blue-800 text-white font-semibold"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Auto-Assign All
|
||||
</Button>
|
||||
<Badge className="bg-blue-100 text-blue-700 border-2 border-blue-300 text-lg px-4 py-2 font-bold">
|
||||
{totalSelected} / {totalNeeded}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search staff by name or location..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{allRoles.map((roleItem) => {
|
||||
const staff = staffByRole[roleItem.key] || [];
|
||||
const selectedSet = assignments[roleItem.key] || new Set();
|
||||
const available = staff.filter(s => !s.hasConflict);
|
||||
|
||||
return (
|
||||
<div key={roleItem.key} className="border-2 border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-slate-50 to-slate-100 px-4 py-3 border-b-2 border-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900">{roleItem.label}</h3>
|
||||
<p className="text-sm text-slate-600">{available.length} available staff</p>
|
||||
</div>
|
||||
<Badge className={`${
|
||||
selectedSet.size >= roleItem.remaining
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: selectedSet.size > 0
|
||||
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||
: 'bg-slate-100 text-slate-600 border-slate-300'
|
||||
} border-2 px-4 py-2 text-lg font-bold`}>
|
||||
{selectedSet.size} / {roleItem.remaining}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{staff.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Users className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No {roleItem.role.role}s found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{staff.map((person) => {
|
||||
const isSelected = selectedSet.has(person.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={person.id}
|
||||
className={`p-3 flex items-center gap-3 cursor-pointer transition-all ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-[#0A39DF]' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleStaffForRole(roleItem.key, person.id, roleItem.remaining, person)}
|
||||
className="w-4 h-4 rounded border-2 text-[#0A39DF]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Avatar className="w-10 h-10">
|
||||
<img
|
||||
src={person.profile_picture || `https://ui-avatars.com/api/?name=${encodeURIComponent(person.employee_name)}&background=0A39DF&color=fff&size=128`}
|
||||
alt={person.employee_name}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900 text-sm">{person.employee_name}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-600">
|
||||
{person.hub_location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{person.hub_location}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-3 h-3 text-amber-500" />
|
||||
{person.reliability}%
|
||||
</span>
|
||||
{person.otAnalysis && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{person.otAnalysis.projectedWeekHours.toFixed(0)}h week
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
{person.hasConflict ? (
|
||||
<Badge className="bg-orange-100 text-orange-700 border border-orange-300 text-xs">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Conflict
|
||||
</Badge>
|
||||
) : person.otAnalysis?.status === 'RED' ? (
|
||||
<Badge className="bg-red-100 text-red-700 border border-red-300 text-xs">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
OT Risk
|
||||
</Badge>
|
||||
) : person.otAnalysis?.status === 'AMBER' ? (
|
||||
<Badge className="bg-amber-100 text-amber-700 border border-amber-300 text-xs">
|
||||
Near OT
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-100 text-green-700 border border-green-300 text-xs">
|
||||
Available
|
||||
</Badge>
|
||||
)}
|
||||
{person.otAnalysis?.summary && (
|
||||
<span className="text-[10px] text-slate-500 text-right max-w-[120px] line-clamp-1">
|
||||
{person.otAnalysis.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{totalSelected > 0 && (
|
||||
<span className="font-semibold text-[#0A39DF]">
|
||||
{totalSelected} of {totalNeeded} positions filled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="border-2">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={totalSelected === 0 || assignMutation.isPending}
|
||||
className="bg-[#0A39DF] hover:bg-blue-700 font-semibold px-8"
|
||||
>
|
||||
{assignMutation.isPending ? (
|
||||
"Assigning..."
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-5 h-5 mr-2" />
|
||||
Assign All ({totalSelected})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* OT Warning Dialog */}
|
||||
{otWarning && (
|
||||
<Dialog open={!!otWarning} onOpenChange={() => setOtWarning(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-700">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Overtime Alert
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-red-900 mb-2">
|
||||
Assigning {otWarning.staff?.employee_name} will trigger overtime:
|
||||
</p>
|
||||
<div className="text-xs text-red-800 space-y-1">
|
||||
<p>• Current week: {otWarning.staff?.otAnalysis?.currentWeekHours?.toFixed(1)}h → {otWarning.staff?.otAnalysis?.projectedWeekHours?.toFixed(1)}h</p>
|
||||
<p>• Current day: {otWarning.staff?.otAnalysis?.currentDayHours?.toFixed(1)}h → {otWarning.staff?.otAnalysis?.projectedDayHours?.toFixed(1)}h</p>
|
||||
<p className="font-semibold mt-2">⚠️ {otWarning.staff?.otAnalysis?.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-slate-900 mb-2">Cost Impact:</p>
|
||||
<div className="text-xs text-slate-700 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Base cost:</span>
|
||||
<span className="font-mono">${otWarning.staff?.otAnalysis?.baseCost?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-red-700 font-semibold">
|
||||
<span>OT premium:</span>
|
||||
<span className="font-mono">+${otWarning.staff?.otAnalysis?.costImpact?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t pt-1 font-bold">
|
||||
<span>Total:</span>
|
||||
<span className="font-mono">${otWarning.staff?.otAnalysis?.totalCost?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600">
|
||||
This assignment requires manager approval. Overtime will be documented for compliance audits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setOtWarning(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmOTAssignment}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Approve OT & Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,687 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { X, Plus, Users, CheckCircle2, XCircle, Clock, Bell, Mail } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
} from "@/components/ui/alert";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function StaffAssignment({ assignedStaff = [], onChange, requestedCount = 0, eventId, eventName, isRapid = false, currentUser }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedStaff, setSelectedStaff] = useState([]);
|
||||
const [filterDepartment, setFilterDepartment] = useState("all");
|
||||
const [filterHub, setFilterHub] = useState("all");
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isVendor = currentUser?.user_role === 'vendor' || currentUser?.role === 'vendor';
|
||||
const canAssignStaff = isVendor;
|
||||
|
||||
const { data: allStaff, isLoading } = useQuery({
|
||||
queryKey: ['staff'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const sendNotificationMutation = useMutation({
|
||||
mutationFn: async ({ staffEmail, staffName, eventName }) => {
|
||||
// In a real scenario, fullStaffDetails.email or similar would be used.
|
||||
// For this example, we're using contact_number or phone as the email placeholder.
|
||||
if (!staffEmail) {
|
||||
throw new Error("Staff member does not have an email address on file.");
|
||||
}
|
||||
return await base44.integrations.Core.SendEmail({
|
||||
to: staffEmail,
|
||||
subject: `You've been assigned to: ${eventName}`,
|
||||
body: `Hi ${staffName},\n\nYou have been assigned to the event: ${eventName}.\n\nPlease confirm your availability as soon as possible.\n\nThank you!`
|
||||
});
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
toast({
|
||||
title: "Notification Sent",
|
||||
description: `${variables.staffName} has been notified via email`,
|
||||
});
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: `Failed to send notification to ${variables.staffName}. ${error.message}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueDepartments = [...new Set(allStaff.map(s => s.department).filter(Boolean))];
|
||||
const uniqueHubs = [...new Set(allStaff.map(s => s.hub_location).filter(Boolean))];
|
||||
|
||||
const remainingSlots = requestedCount > 0 ? requestedCount - assignedStaff.length : Infinity;
|
||||
const isFull = requestedCount > 0 && assignedStaff.length >= requestedCount;
|
||||
|
||||
// Get available (unassigned) staff
|
||||
const availableStaff = allStaff.filter(staff =>
|
||||
!assignedStaff.some(assigned => assigned.staff_id === staff.id)
|
||||
);
|
||||
|
||||
// Apply filters to available staff
|
||||
const filteredAvailableStaff = availableStaff.filter(staff => {
|
||||
const matchesDepartment = filterDepartment === "all" || staff.department === filterDepartment;
|
||||
const matchesHub = filterHub === "all" || staff.hub_location === filterHub;
|
||||
return matchesDepartment && matchesHub;
|
||||
});
|
||||
|
||||
const handleNotifyStaff = async (staffId) => {
|
||||
const staff = assignedStaff.find(s => s.staff_id === staffId);
|
||||
const fullStaffDetails = allStaff.find(s => s.id === staffId);
|
||||
|
||||
if (!staff || !fullStaffDetails) {
|
||||
toast({
|
||||
title: "Staff Not Found",
|
||||
description: "Could not find staff details to send notification.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Using contact_number or phone as email for demonstration as per outline
|
||||
const staffEmail = fullStaffDetails.contact_number || fullStaffDetails.phone;
|
||||
|
||||
if (!staffEmail) {
|
||||
toast({
|
||||
title: "No Contact Information",
|
||||
description: `${staff.staff_name} doesn't have an email address on file.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendNotificationMutation.mutateAsync({
|
||||
staffEmail: staffEmail,
|
||||
staffName: staff.staff_name,
|
||||
eventName: eventName || "the event"
|
||||
});
|
||||
|
||||
// Update notification status in the assignedStaff list
|
||||
const updatedAssignments = assignedStaff.map(s => {
|
||||
if (s.staff_id === staffId) {
|
||||
return {
|
||||
...s,
|
||||
notified: true,
|
||||
notified_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
onChange(updatedAssignments);
|
||||
|
||||
} catch (error) {
|
||||
// Error handled by onSuccess/onError in sendNotificationMutation directly
|
||||
// No need for a separate toast here unless for a specific re-throw
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotifyAll = async () => {
|
||||
const unnotifiedStaff = assignedStaff.filter(s => !s.notified);
|
||||
|
||||
if (unnotifiedStaff.length === 0) {
|
||||
toast({
|
||||
title: "All Staff Notified",
|
||||
description: "All assigned staff have already been notified.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Sending Notifications",
|
||||
description: `Notifying ${unnotifiedStaff.length} staff member${unnotifiedStaff.length !== 1 ? 's' : ''}...`,
|
||||
});
|
||||
|
||||
// Send notifications to all unnotified staff
|
||||
for (const staff of unnotifiedStaff) {
|
||||
// Use await to send notifications sequentially and update status
|
||||
await handleNotifyStaff(staff.staff_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot select more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only select the exact number needed to fill remaining slots
|
||||
const staffToSelect = filteredAvailableStaff
|
||||
.slice(0, remainingSlots > 0 ? remainingSlots : filteredAvailableStaff.length)
|
||||
.map(s => s.id);
|
||||
|
||||
setSelectedStaff(staffToSelect);
|
||||
|
||||
toast({
|
||||
title: "Staff Selected",
|
||||
description: `${staffToSelect.length} staff member${staffToSelect.length !== 1 ? 's' : ''} selected to fill remaining ${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddStaff = async (staff) => {
|
||||
// Strictly enforce the requested count
|
||||
if (requestedCount > 0 && assignedStaff.length >= requestedCount) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `This order requested exactly ${requestedCount} staff. Cannot assign more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlreadyAssigned = assignedStaff.some(s => s.staff_id === staff.id);
|
||||
if (isAlreadyAssigned) {
|
||||
toast({
|
||||
title: "Already Assigned",
|
||||
description: `${staff.employee_name} is already assigned to this event`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newStaff = {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
|
||||
const updatedAssignments = [...assignedStaff, newStaff];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${staff.employee_name} has been assigned to the event`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (requestedCount > 0) {
|
||||
const availableSlots = requestedCount - assignedStaff.length;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast({
|
||||
title: "Assignment Limit Reached",
|
||||
description: `All ${requestedCount} positions are filled. Cannot assign more.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStaff.length > availableSlots) {
|
||||
toast({
|
||||
title: "Assignment Limit",
|
||||
description: `Only ${availableSlots} slot${availableSlots !== 1 ? 's' : ''} remaining. Assigning first ${availableSlots} selected staff.`,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
|
||||
const staffToAssign = selectedStaff.slice(0, availableSlots);
|
||||
|
||||
if (staffToAssign.length === 0) {
|
||||
toast({
|
||||
title: "No Slots Available",
|
||||
description: "All positions are filled. Cannot assign more staff.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignments = staffToAssign
|
||||
.filter(staffId => !assignedStaff.some(s => s.staff_id === staffId))
|
||||
.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignments = [...assignedStaff, ...newAssignments];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
} else {
|
||||
// No limit set, allow unlimited assignments
|
||||
if (selectedStaff.length === 0) {
|
||||
toast({
|
||||
title: "No Staff Selected",
|
||||
description: "Please select staff members to assign",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newAssignments = selectedStaff
|
||||
.filter(staffId => !assignedStaff.some(s => s.staff_id === staffId))
|
||||
.map(staffId => {
|
||||
const staff = allStaff.find(s => s.id === staffId);
|
||||
return {
|
||||
staff_id: staff.id,
|
||||
staff_name: staff.employee_name,
|
||||
position: staff.position || "",
|
||||
confirmed: false,
|
||||
notified: false,
|
||||
notified_at: null,
|
||||
confirmed_at: null
|
||||
};
|
||||
});
|
||||
|
||||
const updatedAssignments = [...assignedStaff, ...newAssignments];
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Assigned",
|
||||
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
|
||||
});
|
||||
|
||||
setSelectedStaff([]);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveStaff = (staffId) => {
|
||||
const updatedAssignments = assignedStaff.filter(s => s.staff_id !== staffId);
|
||||
onChange(updatedAssignments);
|
||||
|
||||
toast({
|
||||
title: "Staff Removed",
|
||||
description: "Staff member has been removed from the event",
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleConfirmation = (staffId) => {
|
||||
const updatedAssignments = assignedStaff.map(staff => {
|
||||
if (staff.staff_id === staffId) {
|
||||
return {
|
||||
...staff,
|
||||
confirmed: !staff.confirmed,
|
||||
confirmed_at: !staff.confirmed ? new Date().toISOString() : null
|
||||
};
|
||||
}
|
||||
return staff;
|
||||
});
|
||||
onChange(updatedAssignments);
|
||||
|
||||
const staff = assignedStaff.find(s => s.staff_id === staffId);
|
||||
toast({
|
||||
title: staff.confirmed ? "Confirmation Removed" : "Staff Confirmed",
|
||||
description: staff.confirmed
|
||||
? `${staff.staff_name}'s confirmation has been removed`
|
||||
: `${staff.staff_name} has been confirmed for this event`,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmedCount = assignedStaff.filter(s => s.confirmed).length;
|
||||
const allConfirmed = assignedStaff.length > 0 && confirmedCount === assignedStaff.length;
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-slate-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0A39DF]" />
|
||||
Staff Assignment
|
||||
{isRapid && requestedCount > 0 && (
|
||||
<Badge className="bg-red-600 text-white">
|
||||
RAPID: {requestedCount} {requestedCount === 1 ? 'position' : 'positions'}
|
||||
</Badge>
|
||||
)}
|
||||
{!isRapid && requestedCount > 0 && (
|
||||
<Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : "border-amber-500 text-amber-700"}>
|
||||
{assignedStaff.length} / {requestedCount}
|
||||
{isFull && " ✓ Full"}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{canAssignStaff && assignedStaff.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNotifyAll}
|
||||
disabled={sendNotificationMutation.isPending || assignedStaff.every(s => s.notified)}
|
||||
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/10"
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notify All
|
||||
</Button>
|
||||
)}
|
||||
{canAssignStaff && (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
|
||||
disabled={isFull && requestedCount > 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{isFull && requestedCount > 0
|
||||
? "Event Fully Staffed"
|
||||
: remainingSlots > 0
|
||||
? `Add Staff (${remainingSlots} needed)`
|
||||
: "Add Staff"
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<div className="p-4 border-b border-slate-200 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Assign Staff Members</h3>
|
||||
<Badge variant={isFull ? "default" : "outline"} className={isFull ? "bg-green-600" : ""}>
|
||||
{assignedStaff.length} / {requestedCount || "∞"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{remainingSlots > 0 && !isFull && (
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertDescription className="text-amber-800 text-xs">
|
||||
<strong>{remainingSlots}</strong> more staff member{remainingSlots !== 1 ? 's' : ''} needed to fill all positions
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isFull && requestedCount > 0 && (
|
||||
<Alert className="bg-green-50 border-green-200">
|
||||
<AlertDescription className="text-green-800 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Event fully staffed - all {requestedCount} positions filled
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Departments</SelectItem>
|
||||
{uniqueDepartments.map(dept => (
|
||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterHub} onValueChange={setFilterHub}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Hub" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Hubs</SelectItem>
|
||||
{uniqueHubs.map(hub => (
|
||||
<SelectItem key={hub} value={hub}>{hub}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSelectAll}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={isFull || filteredAvailableStaff.length === 0}
|
||||
>
|
||||
Select {remainingSlots > 0 ? `${Math.min(remainingSlots, filteredAvailableStaff.length)}` : 'All'} Available
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setSelectedStaff([])}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={selectedStaff.length === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedStaff.length > 0 && !isFull && (
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Assign {Math.min(selectedStaff.length, remainingSlots > 0 ? remainingSlots : selectedStaff.length)} Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Command className="max-h-80 overflow-auto">
|
||||
<CommandInput placeholder="Search staff..." />
|
||||
<CommandEmpty>No available staff found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredAvailableStaff.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-slate-500">
|
||||
{availableStaff.length === 0
|
||||
? "All staff members are already assigned"
|
||||
: "No staff match the selected filters"}
|
||||
</div>
|
||||
) : (
|
||||
filteredAvailableStaff.map((staff) => {
|
||||
const isSelected = selectedStaff.includes(staff.id);
|
||||
const canSelect = !isFull || isSelected;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={staff.id}
|
||||
onSelect={() => {
|
||||
if (!canSelect) return;
|
||||
|
||||
if (isSelected) {
|
||||
setSelectedStaff(prev => prev.filter(id => id !== staff.id));
|
||||
} else {
|
||||
if (requestedCount > 0 && selectedStaff.length >= remainingSlots) {
|
||||
toast({
|
||||
title: "Selection Limit",
|
||||
description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedStaff(prev => [...prev, staff.id]);
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer ${!canSelect ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={!canSelect}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={!canSelect}
|
||||
onCheckedChange={() => {}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white text-xs font-bold">
|
||||
{staff.initial || staff.employee_name?.charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate text-sm">
|
||||
{staff.employee_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{staff.position && <span>{staff.position}</span>}
|
||||
{staff.department && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{staff.department}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-lg text-[#1C323E]">{assignedStaff.length}</span>
|
||||
<span className="text-slate-500"> / {requestedCount || "∞"} assigned</span>
|
||||
</div>
|
||||
{assignedStaff.length > 0 && (
|
||||
<div className="text-sm text-slate-600">
|
||||
<span className="font-semibold text-lg text-green-600">{confirmedCount}</span>
|
||||
<span className="text-slate-500"> confirmed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allConfirmed && assignedStaff.length > 0 && (
|
||||
<Badge className="bg-green-50 text-green-700 border-green-200">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
All Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{assignedStaff.length === 0 ? (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-lg border-2 border-dashed border-slate-200">
|
||||
<Users className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium mb-1">No staff assigned yet</p>
|
||||
<p className="text-sm text-slate-500">Click "Add Staff" to assign team members</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{assignedStaff.map((staff, index) => (
|
||||
<div
|
||||
key={staff.staff_id}
|
||||
className="flex items-center gap-4 p-4 bg-slate-50 rounded-lg border border-slate-200 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
|
||||
{staff.staff_name?.charAt(0) || "?"}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-slate-900">{staff.staff_name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{staff.position && (
|
||||
<p className="text-sm text-slate-500">{staff.position}</p>
|
||||
)}
|
||||
{staff.notified && (
|
||||
<Badge variant="outline" className="text-xs border-[#0A39DF] text-[#0A39DF]">
|
||||
<Bell className="w-3 h-3 mr-1" />
|
||||
Notified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canAssignStaff && !staff.notified && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleNotifyStaff(staff.staff_id)}
|
||||
disabled={sendNotificationMutation.isPending}
|
||||
className="border-[#0A39DF] text-[#0A39DF] hover:bg-[#0A39DF]/10"
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-1" />
|
||||
Notify
|
||||
</Button>
|
||||
)}
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.confirmed ? "default" : "outline"}
|
||||
onClick={() => handleToggleConfirmation(staff.staff_id)}
|
||||
className={staff.confirmed ? "bg-green-600 hover:bg-green-700" : ""}
|
||||
>
|
||||
{staff.confirmed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
Confirmed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Pending
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!canAssignStaff && staff.confirmed && (
|
||||
<Badge className="bg-green-600 text-white">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{canAssignStaff && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveStaff(staff.staff_id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function StatusCard({ status, count, percentage, color }) {
|
||||
const colorClasses = {
|
||||
blue: "from-[#0A39DF] to-[#0A39DF]/80",
|
||||
purple: "from-purple-600 to-purple-700",
|
||||
green: "from-emerald-600 to-emerald-700",
|
||||
gray: "from-slate-600 to-slate-700",
|
||||
yellow: "from-amber-500 to-amber-600"
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-white border-slate-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 mb-2">{status}</p>
|
||||
<div className={`w-14 h-14 bg-gradient-to-br ${colorClasses[color]} rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-md`}>
|
||||
{count}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-[#1C323E]">{percentage}%</div>
|
||||
<p className="text-xs text-slate-500 mt-1">of total</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
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 (
|
||||
<>
|
||||
<Card className={`border-2 ${
|
||||
isRapid
|
||||
? 'border-red-300 bg-gradient-to-br from-red-50 to-orange-50'
|
||||
: 'border-blue-300 bg-gradient-to-br from-blue-50 to-indigo-50'
|
||||
}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 ${
|
||||
isRapid ? 'bg-red-600' : 'bg-blue-600'
|
||||
} rounded-lg flex items-center justify-center`}>
|
||||
{isRapid ? (
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
{isRapid ? 'RAPID ORDER ROUTING' : 'Order Routing'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{routingMode === 'multi'
|
||||
? `Sending to ${selectedVendors.length} vendors`
|
||||
: 'Default vendor routing'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{routingMode === 'multi' && (
|
||||
<Badge className="bg-purple-600 text-white font-bold">
|
||||
MULTI-VENDOR
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Vendor(s) */}
|
||||
<div className="space-y-2">
|
||||
{selectedVendors.length === 0 && !preferredVendor && (
|
||||
<div className="p-3 bg-amber-50 border-2 border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0" />
|
||||
<p className="text-amber-800">
|
||||
<strong>No vendor selected.</strong> Please choose a vendor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVendors.map((vendor) => {
|
||||
const isPreferred = vendor.id === preferredVendor?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vendor.id}
|
||||
className="p-3 bg-white border-2 border-slate-200 rounded-lg hover:border-blue-300 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-sm text-slate-900">
|
||||
{vendor.doing_business_as || vendor.legal_name}
|
||||
</p>
|
||||
{isPreferred && (
|
||||
<Badge className="bg-blue-600 text-white text-xs px-2 py-0 border-0">
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Preferred
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
{vendor.region && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{vendor.region}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{vendor.workforce_count || 0} staff
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{routingMode === 'multi' && (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectionMode('single');
|
||||
setShowVendorSelector(true);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
{selectedVendors.length === 0 ? 'Choose Vendor' : 'Change Vendor'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectionMode('multi');
|
||||
setShowVendorSelector(true);
|
||||
}}
|
||||
className="text-xs bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100"
|
||||
>
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
Multi-Vendor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
{routingMode === 'multi' && (
|
||||
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-2">
|
||||
<p className="text-xs text-purple-800">
|
||||
<strong>Multi-Vendor Mode:</strong> Order sent to all selected vendors.
|
||||
First to confirm gets the job.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRapid && (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-2">
|
||||
<p className="text-xs text-red-800">
|
||||
<strong>RAPID Priority:</strong> This order will be marked urgent with priority notification.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vendor Selector Dialog */}
|
||||
<Dialog open={showVendorSelector} onOpenChange={setShowVendorSelector}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{selectionMode === 'multi' ? (
|
||||
<>
|
||||
<Zap className="w-6 h-6 text-purple-600" />
|
||||
Select Multiple Vendors
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-6 h-6 text-blue-600" />
|
||||
Select Vendor
|
||||
</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 mt-4">
|
||||
{allVendors.map((vendor) => {
|
||||
const isSelected = selectedVendors.some(v => v.id === vendor.id);
|
||||
const isPreferred = vendor.id === preferredVendor?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={vendor.id}
|
||||
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-blue-300 hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => handleVendorSelect(vendor)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-lg text-slate-900">
|
||||
{vendor.doing_business_as || vendor.legal_name}
|
||||
</h3>
|
||||
{isPreferred && (
|
||||
<Badge className="bg-blue-600 text-white">
|
||||
<Award className="w-3 h-3 mr-1" />
|
||||
Preferred
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && selectionMode === 'multi' && (
|
||||
<Badge className="bg-green-600 text-white">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
{vendor.region && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<MapPin className="w-3 h-3 mr-1" />
|
||||
{vendor.region}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.service_specialty && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{vendor.service_specialty}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
{vendor.workforce_count || 0} staff
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
4.9
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||
98% fill rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{allVendors.length === 0 && (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No vendors available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode === 'multi' && (
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<p className="text-sm text-slate-600">
|
||||
{selectedVendors.length} vendor{selectedVendors.length !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleMultiVendorDone}
|
||||
disabled={selectedVendors.length === 0}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Confirm Selection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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 { format, addDays } from "date-fns";
|
||||
|
||||
/**
|
||||
* Auto Invoice Generator Component
|
||||
* Monitors completed events and automatically generates invoices
|
||||
* when all staff have ended their shifts
|
||||
*/
|
||||
export default function AutoInvoiceGenerator() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-invoice-generation'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
refetchInterval: 60000, // Check every minute
|
||||
});
|
||||
|
||||
const { data: invoices = [] } = useQuery({
|
||||
queryKey: ['invoices'],
|
||||
queryFn: () => base44.entities.Invoice.list(),
|
||||
});
|
||||
|
||||
const createInvoiceMutation = useMutation({
|
||||
mutationFn: (invoiceData) => base44.entities.Invoice.create(invoiceData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!events || !invoices) return;
|
||||
|
||||
// Find completed events that don't have invoices yet
|
||||
const completedEvents = events.filter(event =>
|
||||
event.status === "Completed" &&
|
||||
!invoices.some(inv => inv.event_id === event.id)
|
||||
);
|
||||
|
||||
completedEvents.forEach(async (event) => {
|
||||
try {
|
||||
// Group staff by role and generate detailed entries
|
||||
const roleGroups = {};
|
||||
|
||||
if (event.assigned_staff && event.shifts) {
|
||||
event.shifts.forEach(shift => {
|
||||
shift.roles?.forEach(role => {
|
||||
const assignedForRole = event.assigned_staff.filter(
|
||||
s => s.role === role.role
|
||||
);
|
||||
|
||||
if (!roleGroups[role.role]) {
|
||||
roleGroups[role.role] = {
|
||||
role_name: role.role,
|
||||
staff_entries: [],
|
||||
role_subtotal: 0
|
||||
};
|
||||
}
|
||||
|
||||
assignedForRole.forEach(staff => {
|
||||
const workedHours = role.hours || 8;
|
||||
const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
|
||||
|
||||
// Calculate regular, OT, and DT hours
|
||||
const regularHours = Math.min(workedHours, 8);
|
||||
const otHours = Math.max(0, Math.min(workedHours - 8, 4));
|
||||
const dtHours = Math.max(0, workedHours - 12);
|
||||
|
||||
// Calculate rates (OT = 1.5x, DT = 2x)
|
||||
const regularRate = baseRate;
|
||||
const otRate = baseRate * 1.5;
|
||||
const dtRate = baseRate * 2;
|
||||
|
||||
// Calculate values
|
||||
const regularValue = regularHours * regularRate;
|
||||
const otValue = otHours * otRate;
|
||||
const dtValue = dtHours * dtRate;
|
||||
const total = regularValue + otValue + dtValue;
|
||||
|
||||
const entry = {
|
||||
staff_name: staff.staff_name,
|
||||
staff_id: staff.staff_id,
|
||||
date: event.date,
|
||||
position: role.role,
|
||||
check_in: role.start_time || "09:00 AM",
|
||||
check_out: role.end_time || "05:00 PM",
|
||||
worked_hours: workedHours,
|
||||
regular_hours: regularHours,
|
||||
ot_hours: otHours,
|
||||
dt_hours: dtHours,
|
||||
regular_rate: regularRate,
|
||||
ot_rate: otRate,
|
||||
dt_rate: dtRate,
|
||||
regular_value: regularValue,
|
||||
ot_value: otValue,
|
||||
dt_value: dtValue,
|
||||
rate: baseRate,
|
||||
total: total
|
||||
};
|
||||
|
||||
roleGroups[role.role].staff_entries.push(entry);
|
||||
roleGroups[role.role].role_subtotal += total;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const roles = Object.values(roleGroups);
|
||||
const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
|
||||
const otherCharges = 0;
|
||||
const total = subtotal + otherCharges;
|
||||
|
||||
// Generate invoice number
|
||||
const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
// Get vendor and client info
|
||||
const vendorInfo = {
|
||||
name: event.vendor_name || "Legendary",
|
||||
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
|
||||
email: "orders@legendaryeventstaff.com",
|
||||
phone: "(408) 936-0180"
|
||||
};
|
||||
|
||||
const clientInfo = {
|
||||
name: event.business_name || "Client Company",
|
||||
address: event.event_location || "Address",
|
||||
email: event.client_email || "",
|
||||
manager: event.client_name || event.manager_name || "Manager",
|
||||
phone: event.client_phone || "",
|
||||
vendor_id: "Vendor #"
|
||||
};
|
||||
|
||||
// Create invoice
|
||||
const invoiceData = {
|
||||
invoice_number: invoiceNumber,
|
||||
event_id: event.id,
|
||||
event_name: event.event_name,
|
||||
event_date: event.date,
|
||||
po_reference: event.po_reference,
|
||||
from_company: vendorInfo,
|
||||
to_company: clientInfo,
|
||||
business_name: event.business_name,
|
||||
manager_name: event.client_name || event.business_name,
|
||||
vendor_name: event.vendor_name,
|
||||
vendor_id: event.vendor_id,
|
||||
hub: event.hub,
|
||||
cost_center: event.po_reference,
|
||||
roles: roles,
|
||||
subtotal: subtotal,
|
||||
other_charges: otherCharges,
|
||||
amount: total,
|
||||
status: "Pending Review",
|
||||
issue_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
is_auto_generated: true,
|
||||
notes: `Automatically generated invoice for ${event.event_name}`,
|
||||
};
|
||||
|
||||
await createInvoiceMutation.mutateAsync(invoiceData);
|
||||
|
||||
toast({
|
||||
title: "✅ Invoice Generated",
|
||||
description: `Invoice ${invoiceNumber} created for ${event.event_name}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate invoice:', error);
|
||||
}
|
||||
});
|
||||
}, [events, invoices]);
|
||||
|
||||
return null; // This is a background component
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
import { format, addDays } from "date-fns";
|
||||
import { Plus, Trash2, FileEdit } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
export default function CreateInvoiceModal({ open, onClose }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: events = [] } = useQuery({
|
||||
queryKey: ['events-for-invoice'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleAdvancedEditor = () => {
|
||||
onClose();
|
||||
navigate(createPageUrl('InvoiceEditor'));
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data) => {
|
||||
const selectedEvent = events.find(e => e.id === data.event_id);
|
||||
if (!selectedEvent) throw new Error("Event not found");
|
||||
|
||||
// Generate roles and staff entries from event
|
||||
const roleGroups = {};
|
||||
|
||||
if (selectedEvent.assigned_staff && selectedEvent.shifts) {
|
||||
selectedEvent.shifts.forEach(shift => {
|
||||
shift.roles?.forEach(role => {
|
||||
const assignedForRole = selectedEvent.assigned_staff.filter(
|
||||
s => s.role === role.role
|
||||
);
|
||||
|
||||
if (!roleGroups[role.role]) {
|
||||
roleGroups[role.role] = {
|
||||
role_name: role.role,
|
||||
staff_entries: [],
|
||||
role_subtotal: 0
|
||||
};
|
||||
}
|
||||
|
||||
assignedForRole.forEach(staff => {
|
||||
const workedHours = role.hours || 8;
|
||||
const baseRate = role.cost_per_hour || role.rate_per_hour || 0;
|
||||
|
||||
const regularHours = Math.min(workedHours, 8);
|
||||
const otHours = Math.max(0, Math.min(workedHours - 8, 4));
|
||||
const dtHours = Math.max(0, workedHours - 12);
|
||||
|
||||
const regularRate = baseRate;
|
||||
const otRate = baseRate * 1.5;
|
||||
const dtRate = baseRate * 2;
|
||||
|
||||
const regularValue = regularHours * regularRate;
|
||||
const otValue = otHours * otRate;
|
||||
const dtValue = dtHours * dtRate;
|
||||
const total = regularValue + otValue + dtValue;
|
||||
|
||||
const entry = {
|
||||
staff_name: staff.staff_name,
|
||||
staff_id: staff.staff_id,
|
||||
date: selectedEvent.date,
|
||||
position: role.role,
|
||||
check_in: role.start_time || "09:00 AM",
|
||||
check_out: role.end_time || "05:00 PM",
|
||||
worked_hours: workedHours,
|
||||
regular_hours: regularHours,
|
||||
ot_hours: otHours,
|
||||
dt_hours: dtHours,
|
||||
regular_rate: regularRate,
|
||||
ot_rate: otRate,
|
||||
dt_rate: dtRate,
|
||||
regular_value: regularValue,
|
||||
ot_value: otValue,
|
||||
dt_value: dtValue,
|
||||
rate: baseRate,
|
||||
total: total
|
||||
};
|
||||
|
||||
roleGroups[role.role].staff_entries.push(entry);
|
||||
roleGroups[role.role].role_subtotal += total;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const roles = Object.values(roleGroups);
|
||||
const subtotal = roles.reduce((sum, role) => sum + role.role_subtotal, 0);
|
||||
const otherCharges = parseFloat(data.other_charges) || 0;
|
||||
const total = subtotal + otherCharges;
|
||||
|
||||
const invoiceNumber = `INV-${Math.floor(Math.random() * 10000)}`;
|
||||
|
||||
const vendorInfo = {
|
||||
name: selectedEvent.vendor_name || "Legendary",
|
||||
address: "848 E Gish Rd Ste 1, San Jose, CA 95112",
|
||||
email: "orders@legendaryeventstaff.com",
|
||||
phone: "(408) 936-0180"
|
||||
};
|
||||
|
||||
const clientInfo = {
|
||||
name: selectedEvent.business_name || "Client Company",
|
||||
address: selectedEvent.event_location || "Address",
|
||||
email: selectedEvent.client_email || "",
|
||||
manager: selectedEvent.client_name || selectedEvent.manager_name || "Manager",
|
||||
phone: selectedEvent.client_phone || "",
|
||||
vendor_id: "Vendor #"
|
||||
};
|
||||
|
||||
return base44.entities.Invoice.create({
|
||||
invoice_number: invoiceNumber,
|
||||
event_id: selectedEvent.id,
|
||||
event_name: selectedEvent.event_name,
|
||||
event_date: selectedEvent.date,
|
||||
po_reference: data.po_reference || selectedEvent.po_reference,
|
||||
from_company: vendorInfo,
|
||||
to_company: clientInfo,
|
||||
business_name: selectedEvent.business_name,
|
||||
manager_name: selectedEvent.client_name || selectedEvent.business_name,
|
||||
vendor_name: selectedEvent.vendor_name,
|
||||
vendor_id: selectedEvent.vendor_id,
|
||||
hub: selectedEvent.hub,
|
||||
cost_center: data.po_reference || selectedEvent.po_reference,
|
||||
roles: roles,
|
||||
subtotal: subtotal,
|
||||
other_charges: otherCharges,
|
||||
amount: total,
|
||||
status: "Draft",
|
||||
issue_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
due_date: format(addDays(new Date(), 30), 'yyyy-MM-dd'),
|
||||
is_auto_generated: false,
|
||||
notes: data.notes,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast({
|
||||
title: "✅ Invoice Created",
|
||||
description: "Invoice has been created successfully",
|
||||
});
|
||||
onClose();
|
||||
setFormData({ event_id: "", po_reference: "", other_charges: 0, notes: "" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.event_id) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please select an event",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const completedEvents = events.filter(e => e.status === "Completed");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Invoice</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6 text-center">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
<FileEdit className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Ready to create an invoice?</h3>
|
||||
<p className="text-slate-600 mb-6">Use the advanced editor to create a detailed invoice with full control.</p>
|
||||
|
||||
<Button
|
||||
onClick={handleAdvancedEditor}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold h-12"
|
||||
>
|
||||
<FileEdit className="w-5 h-5 mr-2" />
|
||||
Open Invoice Editor
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
FileText, Download, Mail, Printer, CheckCircle,
|
||||
XCircle, AlertTriangle, DollarSign, Calendar, Building2,
|
||||
User, CreditCard, Edit3, Flag, CheckCheck
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
const statusColors = {
|
||||
'Draft': 'bg-slate-500',
|
||||
'Pending Review': 'bg-amber-500',
|
||||
'Approved': 'bg-green-500',
|
||||
'Disputed': 'bg-red-500',
|
||||
'Under Review': 'bg-orange-500',
|
||||
'Resolved': 'bg-blue-500',
|
||||
'Overdue': 'bg-red-600',
|
||||
'Paid': 'bg-emerald-500',
|
||||
'Reconciled': 'bg-purple-500',
|
||||
'Cancelled': 'bg-slate-400',
|
||||
};
|
||||
|
||||
export default function InvoiceDetailModal({ open, onClose, invoice, userRole }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [disputeMode, setDisputeMode] = useState(false);
|
||||
const [disputeReason, setDisputeReason] = useState("");
|
||||
const [disputeDetails, setDisputeDetails] = useState("");
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const [paymentRef, setPaymentRef] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState([]);
|
||||
|
||||
const updateInvoiceMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => base44.entities.Invoice.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
toast({
|
||||
title: "✅ Invoice Updated",
|
||||
description: "Invoice has been updated successfully",
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleApprove = async () => {
|
||||
const user = await base44.auth.me();
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Approved",
|
||||
approved_by: user.email,
|
||||
approved_date: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDispute = async () => {
|
||||
const user = await base44.auth.me();
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Disputed",
|
||||
dispute_reason: disputeReason,
|
||||
dispute_details: disputeDetails,
|
||||
disputed_items: selectedItems,
|
||||
disputed_by: user.email,
|
||||
disputed_date: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
updateInvoiceMutation.mutate({
|
||||
id: invoice.id,
|
||||
data: {
|
||||
status: "Paid",
|
||||
paid_date: new Date().toISOString().split('T')[0],
|
||||
payment_method: paymentMethod,
|
||||
payment_reference: paymentRef,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadPDF = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleEmailInvoice = async () => {
|
||||
const user = await base44.auth.me();
|
||||
await base44.integrations.Core.SendEmail({
|
||||
to: invoice.business_name || user.email,
|
||||
subject: `Invoice ${invoice.invoice_number}`,
|
||||
body: `Please find attached invoice ${invoice.invoice_number} for ${invoice.event_name}. Amount: $${invoice.amount}. Due: ${invoice.due_date}`,
|
||||
});
|
||||
toast({
|
||||
title: "✅ Email Sent",
|
||||
description: "Invoice has been emailed successfully",
|
||||
});
|
||||
};
|
||||
|
||||
const toggleItemSelection = (index) => {
|
||||
setSelectedItems(prev =>
|
||||
prev.includes(index) ? prev.filter(i => i !== index) : [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
if (!invoice) return null;
|
||||
|
||||
const isClient = userRole === "client";
|
||||
const isVendor = userRole === "vendor";
|
||||
const canApprove = isClient && invoice.status === "Pending Review";
|
||||
const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
|
||||
const canPay = isClient && ["Approved", "Overdue"].includes(invoice.status);
|
||||
const canEdit = isVendor && ["Draft", "Disputed"].includes(invoice.status);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold">{invoice.invoice_number}</DialogTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">{invoice.event_name}</p>
|
||||
</div>
|
||||
<Badge className={`${statusColors[invoice.status]} text-white px-4 py-2`}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Header Information */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* From Section */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-[#0A39DF] rounded-full flex items-center justify-center">
|
||||
<Building2 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900">From:</h3>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{invoice.from_company?.name || invoice.vendor_name}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.address}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.email}</p>
|
||||
<p className="text-slate-600">{invoice.from_company?.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* To Section */}
|
||||
<div className="bg-green-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 bg-green-600 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900">To:</h3>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{invoice.to_company?.name || invoice.business_name}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.address}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.email}</p>
|
||||
<p className="font-semibold text-slate-900 mt-2">{invoice.to_company?.manager || invoice.manager_name}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.phone}</p>
|
||||
<p className="text-slate-600">{invoice.to_company?.vendor_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500">Event Date:</span>
|
||||
<span className="ml-2 font-semibold">{invoice.event_date ? format(parseISO(invoice.event_date), 'MMM dd, yyyy') : '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">PO #:</span>
|
||||
<span className="ml-2 font-semibold">{invoice.po_reference || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">Due Date:</span>
|
||||
<span className="ml-2 font-semibold text-red-600">{format(parseISO(invoice.due_date), 'MMM dd, yyyy')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Roles and Staff Charges */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold text-lg">Staff Charges</h3>
|
||||
|
||||
{invoice.roles?.map((roleGroup, roleIdx) => (
|
||||
<div key={roleIdx} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-slate-100 px-4 py-2 border-b border-slate-200">
|
||||
<h4 className="font-bold text-slate-900">Role: {roleGroup.role_name}</h4>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{disputeMode && <th className="p-2 text-left font-semibold">Flag</th>}
|
||||
<th className="p-2 text-left font-semibold">Name</th>
|
||||
<th className="p-2 text-left font-semibold">Check-In</th>
|
||||
<th className="p-2 text-left font-semibold">Check-Out</th>
|
||||
<th className="p-2 text-right font-semibold">Worked</th>
|
||||
<th className="p-2 text-right font-semibold">Reg Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">OT Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">DT Hrs</th>
|
||||
<th className="p-2 text-right font-semibold">Rate</th>
|
||||
<th className="p-2 text-right font-semibold">Reg Value</th>
|
||||
<th className="p-2 text-right font-semibold">OT Value</th>
|
||||
<th className="p-2 text-right font-semibold">DT Value</th>
|
||||
<th className="p-2 text-right font-semibold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roleGroup.staff_entries?.map((entry, entryIdx) => (
|
||||
<tr key={entryIdx} className={`border-t border-slate-200 ${selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx) ? 'bg-red-50' : ''}`}>
|
||||
{disputeMode && (
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedItems.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)}
|
||||
onChange={() => {
|
||||
const itemId = { role_index: roleIdx, staff_index: entryIdx };
|
||||
setSelectedItems(prev =>
|
||||
prev.some(item => item.role_index === roleIdx && item.staff_index === entryIdx)
|
||||
? prev.filter(item => !(item.role_index === roleIdx && item.staff_index === entryIdx))
|
||||
: [...prev, itemId]
|
||||
);
|
||||
}}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="p-2">{entry.staff_name}</td>
|
||||
<td className="p-2">{entry.check_in}</td>
|
||||
<td className="p-2">{entry.check_out}</td>
|
||||
<td className="p-2 text-right">{entry.worked_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.regular_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.ot_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">{entry.dt_hours?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.rate?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.regular_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.ot_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right">${entry.dt_value?.toFixed(2)}</td>
|
||||
<td className="p-2 text-right font-bold">${entry.total?.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="bg-slate-50 px-4 py-2 border-t border-slate-200 flex justify-end">
|
||||
<span className="font-bold">Role Total: ${roleGroup.role_subtotal?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Sub-total:</span>
|
||||
<span className="font-semibold">${invoice.subtotal?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-600">Other charges:</span>
|
||||
<span className="font-semibold">${(invoice.other_charges || 0)?.toFixed(2)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between text-lg">
|
||||
<span className="font-bold">Grand total:</span>
|
||||
<span className="font-bold text-[#0A39DF]">${invoice.amount?.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dispute Section */}
|
||||
{disputeMode && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<h3 className="font-semibold text-red-900">Dispute Invoice</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reason for Dispute</Label>
|
||||
<Select value={disputeReason} onValueChange={setDisputeReason}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select reason" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Incorrect Hours">Incorrect Hours</SelectItem>
|
||||
<SelectItem value="Incorrect Rate">Incorrect Rate</SelectItem>
|
||||
<SelectItem value="Unauthorized Staff">Unauthorized Staff</SelectItem>
|
||||
<SelectItem value="Service Not Rendered">Service Not Rendered</SelectItem>
|
||||
<SelectItem value="Other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Details</Label>
|
||||
<Textarea
|
||||
value={disputeDetails}
|
||||
onChange={(e) => setDisputeDetails(e.target.value)}
|
||||
placeholder="Provide detailed information about the dispute..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Section */}
|
||||
{invoice.status === "Approved" && isClient && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Record Payment</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Payment Method</Label>
|
||||
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Credit Card">Credit Card</SelectItem>
|
||||
<SelectItem value="ACH">ACH Transfer</SelectItem>
|
||||
<SelectItem value="Wire Transfer">Wire Transfer</SelectItem>
|
||||
<SelectItem value="Check">Check</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Reference Number</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={paymentRef}
|
||||
onChange={(e) => setPaymentRef(e.target.value)}
|
||||
placeholder="Transaction ID"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dispute Info */}
|
||||
{invoice.status === "Disputed" && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-amber-900 mb-2">Dispute Information</h3>
|
||||
<p className="text-sm text-slate-700"><strong>Reason:</strong> {invoice.dispute_reason}</p>
|
||||
<p className="text-sm text-slate-700 mt-1"><strong>Details:</strong> {invoice.dispute_details}</p>
|
||||
<p className="text-xs text-slate-500 mt-2">Disputed by {invoice.disputed_by} on {format(parseISO(invoice.disputed_date), 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Notes</Label>
|
||||
<p className="text-sm text-slate-600 mt-1">{invoice.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2 flex-wrap w-full justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDownloadPDF}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleEmailInvoice}>
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
Email
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{canApprove && (
|
||||
<Button onClick={handleApprove} className="bg-green-600 hover:bg-green-700">
|
||||
<CheckCheck className="w-4 h-4 mr-2" />
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDispute && !disputeMode && (
|
||||
<Button onClick={() => setDisputeMode(true)} variant="destructive">
|
||||
<Flag className="w-4 h-4 mr-2" />
|
||||
Dispute
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{disputeMode && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDisputeMode(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDispute} variant="destructive" disabled={!disputeReason}>
|
||||
Submit Dispute
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canPay && (
|
||||
<Button
|
||||
onClick={handlePay}
|
||||
disabled={!paymentMethod}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
<DollarSign className="w-4 h-4 mr-2" />
|
||||
Mark as Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button variant="outline">
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,316 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Download, FileText, FileSpreadsheet, Code, Send, Check, Loader2, Building2, Link2 } from "lucide-react";
|
||||
|
||||
const ERP_SYSTEMS = {
|
||||
"SAP Ariba": { format: "cXML", color: "bg-blue-100 text-blue-700" },
|
||||
"Fieldglass": { format: "CSV", color: "bg-purple-100 text-purple-700" },
|
||||
"CrunchTime": { format: "JSON", color: "bg-orange-100 text-orange-700" },
|
||||
"Coupa": { format: "cXML", color: "bg-teal-100 text-teal-700" },
|
||||
"Oracle NetSuite": { format: "CSV", color: "bg-red-100 text-red-700" },
|
||||
"Workday": { format: "JSON", color: "bg-green-100 text-green-700" },
|
||||
};
|
||||
|
||||
export default function InvoiceExportPanel({ invoice, business, onExport }) {
|
||||
const [exportFormat, setExportFormat] = useState(business?.edi_format || "CSV");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportSuccess, setExportSuccess] = useState(false);
|
||||
|
||||
const erpSystem = business?.erp_system || "None";
|
||||
const erpInfo = ERP_SYSTEMS[erpSystem];
|
||||
|
||||
const generateEDI810 = () => {
|
||||
// EDI 810 Invoice format
|
||||
const segments = [
|
||||
`ISA*00* *00* *ZZ*KROW *ZZ*${business?.erp_vendor_id || 'CLIENT'} *${new Date().toISOString().slice(2,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,5).replace(':','')}*U*00401*000000001*0*P*>~`,
|
||||
`GS*IN*KROW*${business?.business_name?.substring(0,15) || 'CLIENT'}*${new Date().toISOString().slice(0,10).replace(/-/g,'')}*${new Date().toTimeString().slice(0,4).replace(':','')}*1*X*004010~`,
|
||||
`ST*810*0001~`,
|
||||
`BIG*${invoice.issue_date?.replace(/-/g,'')}*${invoice.invoice_number}*${invoice.event_date?.replace(/-/g,'')}*${invoice.po_reference || ''}~`,
|
||||
`N1*BT*${business?.business_name || invoice.business_name}~`,
|
||||
`N1*ST*${invoice.hub || business?.hub_building || ''}~`,
|
||||
`ITD*01*3*****${invoice.due_date?.replace(/-/g,'')}~`,
|
||||
`TDS*${Math.round((invoice.amount || 0) * 100)}~`,
|
||||
`SE*8*0001~`,
|
||||
`GE*1*1~`,
|
||||
`IEA*1*000000001~`
|
||||
];
|
||||
return segments.join('\n');
|
||||
};
|
||||
|
||||
const generateCXML = () => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.014/InvoiceDetail.dtd">
|
||||
<cXML payloadID="${invoice.invoice_number}@krow.com" timestamp="${new Date().toISOString()}">
|
||||
<Header>
|
||||
<From><Credential domain="DUNS"><Identity>KROW</Identity></Credential></From>
|
||||
<To><Credential domain="DUNS"><Identity>${business?.erp_vendor_id || 'CLIENT'}</Identity></Credential></To>
|
||||
<Sender><Credential domain="DUNS"><Identity>KROW</Identity></Credential></Sender>
|
||||
</Header>
|
||||
<Request>
|
||||
<InvoiceDetailRequest>
|
||||
<InvoiceDetailRequestHeader invoiceID="${invoice.invoice_number}" invoiceDate="${invoice.issue_date}" purpose="standard">
|
||||
<InvoiceDetailHeaderIndicator/>
|
||||
<InvoicePartner><Contact role="billTo"><Name>${invoice.business_name}</Name></Contact></InvoicePartner>
|
||||
<PaymentTerm payInNumberOfDays="30"/>
|
||||
</InvoiceDetailRequestHeader>
|
||||
<InvoiceDetailOrder>
|
||||
<InvoiceDetailOrderInfo><OrderReference orderID="${invoice.po_reference || ''}"/></InvoiceDetailOrderInfo>
|
||||
<InvoiceDetailItem invoiceLineNumber="1" quantity="1">
|
||||
<UnitOfMeasure>EA</UnitOfMeasure>
|
||||
<UnitPrice><Money currency="USD">${invoice.amount}</Money></UnitPrice>
|
||||
<InvoiceDetailItemReference lineNumber="1"><Description>${invoice.event_name}</Description></InvoiceDetailItemReference>
|
||||
</InvoiceDetailItem>
|
||||
</InvoiceDetailOrder>
|
||||
<InvoiceDetailSummary>
|
||||
<SubtotalAmount><Money currency="USD">${invoice.subtotal || invoice.amount}</Money></SubtotalAmount>
|
||||
<Tax><Money currency="USD">${invoice.tax_amount || 0}</Money></Tax>
|
||||
<GrossAmount><Money currency="USD">${invoice.amount}</Money></GrossAmount>
|
||||
<InvoiceDetailDiscount/>
|
||||
<NetAmount><Money currency="USD">${invoice.amount}</Money></NetAmount>
|
||||
<DueAmount><Money currency="USD">${invoice.amount}</Money></DueAmount>
|
||||
</InvoiceDetailSummary>
|
||||
</InvoiceDetailRequest>
|
||||
</Request>
|
||||
</cXML>`;
|
||||
};
|
||||
|
||||
const generateCSV = () => {
|
||||
const headers = [
|
||||
"Invoice Number", "Invoice Date", "Due Date", "PO Number", "Vendor ID",
|
||||
"Client Name", "Hub", "Event Name", "Cost Center", "Subtotal", "Tax", "Total Amount", "Status"
|
||||
];
|
||||
const row = [
|
||||
invoice.invoice_number,
|
||||
invoice.issue_date,
|
||||
invoice.due_date,
|
||||
invoice.po_reference || "",
|
||||
business?.erp_vendor_id || "",
|
||||
invoice.business_name,
|
||||
invoice.hub || "",
|
||||
invoice.event_name,
|
||||
business?.erp_cost_center || "",
|
||||
invoice.subtotal || invoice.amount,
|
||||
invoice.tax_amount || 0,
|
||||
invoice.amount,
|
||||
invoice.status
|
||||
];
|
||||
|
||||
// Add line items if available
|
||||
let lineItems = "\n\nLine Item Details\nRole,Staff Name,Date,Hours,Rate,Amount\n";
|
||||
if (invoice.roles) {
|
||||
invoice.roles.forEach(role => {
|
||||
role.staff_entries?.forEach(entry => {
|
||||
lineItems += `${role.role_name},${entry.staff_name},${entry.date},${entry.worked_hours},${entry.rate},${entry.total}\n`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return headers.join(",") + "\n" + row.join(",") + lineItems;
|
||||
};
|
||||
|
||||
const generateJSON = () => {
|
||||
return JSON.stringify({
|
||||
invoice: {
|
||||
invoice_number: invoice.invoice_number,
|
||||
issue_date: invoice.issue_date,
|
||||
due_date: invoice.due_date,
|
||||
po_reference: invoice.po_reference,
|
||||
vendor: {
|
||||
id: business?.erp_vendor_id,
|
||||
name: "KROW Workforce"
|
||||
},
|
||||
client: {
|
||||
name: invoice.business_name,
|
||||
hub: invoice.hub,
|
||||
cost_center: business?.erp_cost_center
|
||||
},
|
||||
event: {
|
||||
name: invoice.event_name,
|
||||
date: invoice.event_date
|
||||
},
|
||||
amounts: {
|
||||
subtotal: invoice.subtotal || invoice.amount,
|
||||
tax: invoice.tax_amount || 0,
|
||||
total: invoice.amount
|
||||
},
|
||||
line_items: invoice.roles?.flatMap(role =>
|
||||
role.staff_entries?.map(entry => ({
|
||||
role: role.role_name,
|
||||
staff_name: entry.staff_name,
|
||||
date: entry.date,
|
||||
hours: entry.worked_hours,
|
||||
rate: entry.rate,
|
||||
amount: entry.total
|
||||
})) || []
|
||||
) || [],
|
||||
status: invoice.status
|
||||
}
|
||||
}, null, 2);
|
||||
};
|
||||
|
||||
const handleExport = async (format) => {
|
||||
setIsExporting(true);
|
||||
|
||||
let content, filename, mimeType;
|
||||
|
||||
switch (format) {
|
||||
case "EDI 810":
|
||||
content = generateEDI810();
|
||||
filename = `${invoice.invoice_number}_EDI810.edi`;
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case "cXML":
|
||||
content = generateCXML();
|
||||
filename = `${invoice.invoice_number}.xml`;
|
||||
mimeType = "application/xml";
|
||||
break;
|
||||
case "JSON":
|
||||
content = generateJSON();
|
||||
filename = `${invoice.invoice_number}.json`;
|
||||
mimeType = "application/json";
|
||||
break;
|
||||
case "CSV":
|
||||
default:
|
||||
content = generateCSV();
|
||||
filename = `${invoice.invoice_number}.csv`;
|
||||
mimeType = "text/csv";
|
||||
break;
|
||||
}
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
setIsExporting(false);
|
||||
setExportSuccess(true);
|
||||
setTimeout(() => setExportSuccess(false), 3000);
|
||||
|
||||
if (onExport) onExport(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5 text-blue-600" />
|
||||
ERP / EDI Export
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* ERP System Info */}
|
||||
{erpSystem !== "None" && (
|
||||
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-slate-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">Connected ERP</p>
|
||||
<p className="text-xs text-slate-500">Vendor ID: {business?.erp_vendor_id || "Not configured"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={erpInfo?.color || "bg-slate-100 text-slate-700"}>
|
||||
{erpSystem}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Format Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Export Format</label>
|
||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CSV">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
CSV (Excel Compatible)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="EDI 810">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
EDI 810 (Standard Invoice)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="cXML">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
cXML (Ariba/Coupa)
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="JSON">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-4 h-4" />
|
||||
JSON (API Format)
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Export Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleExport(exportFormat)}
|
||||
disabled={isExporting}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : exportSuccess ? (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{exportSuccess ? "Exported!" : "Download"}
|
||||
</Button>
|
||||
|
||||
{business?.invoice_email && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {/* Send via email logic */}}
|
||||
className="border-slate-300"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Export Buttons */}
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Quick Export</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("CSV")} className="text-xs">
|
||||
<FileSpreadsheet className="w-3 h-3 mr-1" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("EDI 810")} className="text-xs">
|
||||
<FileText className="w-3 h-3 mr-1" />
|
||||
EDI
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("cXML")} className="text-xs">
|
||||
<Code className="w-3 h-3 mr-1" />
|
||||
cXML
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleExport("JSON")} className="text-xs">
|
||||
<Code className="w-3 h-3 mr-1" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar, Copy, FileText, Search, Clock, Users, MapPin, Zap, Save, Star, Trash2 } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
export default function InvoiceQuickActions({
|
||||
events = [],
|
||||
invoices = [],
|
||||
templates = [],
|
||||
onImportFromEvent,
|
||||
onDuplicateInvoice,
|
||||
onUseTemplate,
|
||||
onSaveTemplate,
|
||||
onDeleteTemplate
|
||||
}) {
|
||||
const [eventDialogOpen, setEventDialogOpen] = useState(false);
|
||||
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
|
||||
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
|
||||
// Filter completed events that can be invoiced
|
||||
const completedEvents = events.filter(e =>
|
||||
e.status === "Completed" || e.status === "Active" || e.status === "Confirmed"
|
||||
);
|
||||
|
||||
// Filter events by search
|
||||
const filteredEvents = completedEvents.filter(e =>
|
||||
e.event_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
e.hub?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter invoices for duplication
|
||||
const filteredInvoices = invoices.filter(inv =>
|
||||
inv.invoice_number?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.business_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
inv.event_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter templates
|
||||
const filteredTemplates = templates.filter(t =>
|
||||
t.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.client_name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleImportEvent = (event) => {
|
||||
onImportFromEvent(event);
|
||||
setEventDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleDuplicate = (invoice) => {
|
||||
onDuplicateInvoice(invoice);
|
||||
setDuplicateDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template) => {
|
||||
onUseTemplate(template);
|
||||
setTemplateDialogOpen(false);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
if (templateName.trim()) {
|
||||
onSaveTemplate(templateName.trim());
|
||||
setSaveTemplateDialogOpen(false);
|
||||
setTemplateName("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-amber-50 to-orange-100 rounded-xl border-2 border-amber-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-amber-500 rounded-lg flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900">Quick Actions</h3>
|
||||
<p className="text-xs text-amber-700">Save time with these shortcuts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Import from Event */}
|
||||
<Dialog open={eventDialogOpen} onOpenChange={setEventDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Import from Event
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
Import Staff Data from Event
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search events by name, client, or hub..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Calendar className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No completed events found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredEvents.map(event => (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => handleImportEvent(event)}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-slate-900">{event.event_name}</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{event.assigned_staff?.length || 0} staff
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{event.hub || event.event_location || "—"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{event.date ? format(parseISO(event.date), 'MMM d, yyyy') : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{event.business_name}</p>
|
||||
</div>
|
||||
<Badge className={`${event.status === 'Completed' ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{event.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Duplicate Invoice */}
|
||||
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate Invoice
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Copy className="w-5 h-5 text-purple-600" />
|
||||
Duplicate an Existing Invoice
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search by invoice #, client, or event..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredInvoices.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No invoices found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredInvoices.slice(0, 20).map(invoice => (
|
||||
<button
|
||||
key={invoice.id}
|
||||
onClick={() => handleDuplicate(invoice)}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-purple-400 hover:bg-purple-50 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-slate-900">{invoice.invoice_number}</h4>
|
||||
<Badge variant="outline" className="text-xs">${invoice.amount?.toLocaleString() || 0}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700 mt-1">{invoice.business_name}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
<span>{invoice.event_name || "No event"}</span>
|
||||
<span>•</span>
|
||||
<span>{invoice.roles?.[0]?.staff_entries?.length || 0} staff entries</span>
|
||||
<span>•</span>
|
||||
<span>{invoice.issue_date ? format(parseISO(invoice.issue_date), 'MMM d, yyyy') : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${invoice.status === 'Paid' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Use Template */}
|
||||
<Dialog open={templateDialogOpen} onOpenChange={setTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Use Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-emerald-600" />
|
||||
Invoice Templates
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 space-y-2 pr-2">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Star className="w-12 h-12 mx-auto mb-2 text-slate-300" />
|
||||
<p>No templates saved yet</p>
|
||||
<p className="text-xs mt-1">Create an invoice and save it as a template</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="w-full p-4 bg-white border border-slate-200 rounded-lg hover:border-emerald-400 hover:bg-emerald-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<button
|
||||
onClick={() => handleUseTemplate(template)}
|
||||
className="flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<h4 className="font-semibold text-slate-900">{template.name}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mt-1">{template.client_name}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-slate-500">
|
||||
<span>{template.staff_count || 0} staff entries</span>
|
||||
<span>•</span>
|
||||
<span>{template.charges_count || 0} charges</span>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteTemplate(template.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Save as Template */}
|
||||
<Dialog open={saveTemplateDialogOpen} onOpenChange={setSaveTemplateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="bg-white border-amber-300 hover:bg-amber-50 text-amber-800">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save as Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Save className="w-5 h-5 text-blue-600" />
|
||||
Save Current Invoice as Template
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Template Name</label>
|
||||
<Input
|
||||
placeholder="e.g., Google Weekly Catering, Standard Event Setup..."
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
This will save client info, positions, rates, and charges as a reusable template
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSaveTemplateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import { MessageSquare, Users } from "lucide-react";
|
||||
|
||||
export default function ConversationList({ conversations, selectedId, onSelect }) {
|
||||
const getTypeColor = (type) => {
|
||||
const colors = {
|
||||
"client-vendor": "bg-purple-100 text-purple-700",
|
||||
"staff-client": "bg-blue-100 text-blue-700",
|
||||
"staff-admin": "bg-slate-100 text-slate-700",
|
||||
"vendor-admin": "bg-amber-100 text-amber-700",
|
||||
"client-admin": "bg-green-100 text-green-700",
|
||||
"group-staff": "bg-indigo-100 text-indigo-700",
|
||||
"group-event-staff": "bg-pink-100 text-pink-700"
|
||||
};
|
||||
return colors[type] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-500">No conversations yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation) => {
|
||||
const isSelected = conversation.id === selectedId;
|
||||
const otherParticipant = conversation.participants?.[1] || conversation.participants?.[0] || {};
|
||||
const isGroup = conversation.is_group;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={conversation.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
isSelected ? 'border-[#0A39DF] border-2 bg-blue-50' : 'border-slate-200'
|
||||
}`}
|
||||
onClick={() => onSelect(conversation)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{isGroup ? (
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
|
||||
{otherParticipant.name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-slate-900 truncate">
|
||||
{isGroup ? conversation.group_name : conversation.subject || otherParticipant.name || "Conversation"}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{isGroup && (
|
||||
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200">
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
{conversation.participants?.length || 0} members
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-xs ${getTypeColor(conversation.conversation_type)}`}>
|
||||
{conversation.conversation_type?.replace('-', ' → ').replace('group-', '')}
|
||||
</Badge>
|
||||
{conversation.related_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{conversation.related_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversation.unread_count > 0 && (
|
||||
<Badge className="bg-red-500 text-white ml-2">
|
||||
{conversation.unread_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600 truncate mt-2">
|
||||
{conversation.last_message || "No messages yet"}
|
||||
</p>
|
||||
|
||||
{conversation.last_message_at && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{format(new Date(conversation.last_message_at), "MMM d, h:mm a")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Paperclip, Loader2 } from "lucide-react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
|
||||
export default function MessageInput({ conversationId, onMessageSent, currentUser }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim() || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await base44.entities.Message.create({
|
||||
conversation_id: conversationId,
|
||||
sender_id: currentUser.id,
|
||||
sender_name: currentUser.full_name || currentUser.email,
|
||||
sender_role: currentUser.role || "admin",
|
||||
content: message.trim(),
|
||||
read_by: [currentUser.id]
|
||||
});
|
||||
|
||||
await base44.entities.Conversation.update(conversationId, {
|
||||
last_message: message.trim().substring(0, 100),
|
||||
last_message_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
setMessage("");
|
||||
onMessageSent?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-200 p-4 bg-white">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Type your message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="min-h-[60px] max-h-[120px]"
|
||||
disabled={sending}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || sending}
|
||||
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
|
||||
>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { format } from "date-fns";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
// Safe date formatter
|
||||
const safeFormatDate = (dateString, formatStr) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return format(date, formatStr);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export default function MessageThread({ messages, currentUserId }) {
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
const colors = {
|
||||
client: "bg-purple-100 text-purple-700",
|
||||
vendor: "bg-amber-100 text-amber-700",
|
||||
staff: "bg-blue-100 text-blue-700",
|
||||
admin: "bg-slate-100 text-slate-700"
|
||||
};
|
||||
return colors[role] || "bg-slate-100 text-slate-700";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 p-4 overflow-y-auto max-h-[500px]">
|
||||
{messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === currentUserId || message.created_by === currentUserId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`flex gap-3 max-w-[70%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<Avatar className="w-8 h-8 flex-shrink-0">
|
||||
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
|
||||
{message.sender_name?.charAt(0) || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-slate-700">{message.sender_name}</span>
|
||||
<Badge variant="outline" className={`text-xs ${getRoleColor(message.sender_role)}`}>
|
||||
{message.sender_role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card className={`p-3 ${isOwnMessage ? 'bg-[#0A39DF] text-white' : 'bg-white'}`}>
|
||||
<p className="text-sm">{message.content}</p>
|
||||
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{message.attachments.map((attachment, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={attachment.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-xs hover:underline"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{attachment.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<span className="text-xs text-slate-500 mt-1">
|
||||
{safeFormatDate(message.created_date, "MMM d, h:mm a")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
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;
|
||||
@@ -1,622 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
X,
|
||||
Bell,
|
||||
Calendar,
|
||||
UserPlus,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
ArrowRight,
|
||||
MoreVertical,
|
||||
CheckSquare,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { formatDistanceToNow, format, isToday, isYesterday, isThisWeek, startOfDay } from "date-fns";
|
||||
|
||||
const iconMap = {
|
||||
calendar: Calendar,
|
||||
user: UserPlus,
|
||||
invoice: FileText,
|
||||
message: MessageSquare,
|
||||
alert: AlertCircle,
|
||||
check: CheckCircle,
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
blue: "bg-blue-100 text-blue-600",
|
||||
red: "bg-red-100 text-red-600",
|
||||
green: "bg-green-100 text-green-600",
|
||||
yellow: "bg-yellow-100 text-yellow-600",
|
||||
purple: "bg-purple-100 text-purple-600",
|
||||
};
|
||||
|
||||
export default function NotificationPanel({ isOpen, onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-notifications'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: notifications = [] } = useQuery({
|
||||
queryKey: ['activity-logs', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return [];
|
||||
|
||||
// Create sample notifications if none exist
|
||||
const existing = await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
|
||||
|
||||
if (existing.length === 0 && user?.id) {
|
||||
// Create initial sample notifications
|
||||
await base44.entities.ActivityLog.bulkCreate([
|
||||
{
|
||||
title: "Event Rescheduled",
|
||||
description: "Team Meeting was moved to July 15, 3:00 PM",
|
||||
activity_type: "event_rescheduled",
|
||||
related_entity_type: "event",
|
||||
action_label: "View Event",
|
||||
icon_type: "calendar",
|
||||
icon_color: "blue",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Event Canceled",
|
||||
description: "Product Demo scheduled for May 20 has been canceled",
|
||||
activity_type: "event_canceled",
|
||||
related_entity_type: "event",
|
||||
action_label: "View Event",
|
||||
icon_type: "calendar",
|
||||
icon_color: "red",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Invoice Paid",
|
||||
description: "You've been added to Client Kickoff on June 8, 10:00 AM",
|
||||
activity_type: "invoice_paid",
|
||||
related_entity_type: "invoice",
|
||||
action_label: "View Invoice",
|
||||
icon_type: "invoice",
|
||||
icon_color: "green",
|
||||
is_read: false,
|
||||
user_id: user.id
|
||||
},
|
||||
{
|
||||
title: "Staff Selected",
|
||||
description: "10 staff members selected to fill remaining 10 slots",
|
||||
activity_type: "staff_assigned",
|
||||
related_entity_type: "event",
|
||||
icon_type: "user",
|
||||
icon_color: "purple",
|
||||
is_read: true,
|
||||
user_id: user.id
|
||||
}
|
||||
]);
|
||||
|
||||
return await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
|
||||
}
|
||||
|
||||
return existing;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
const markAsReadMutation = useMutation({
|
||||
mutationFn: ({ id }) => base44.entities.ActivityLog.update(id, { is_read: true }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id }) => base44.entities.ActivityLog.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Categorize by type
|
||||
const categorizeByType = (notif) => {
|
||||
const type = notif.activity_type || '';
|
||||
const title = (notif.title || '').toLowerCase();
|
||||
|
||||
if (type.includes('message') || title.includes('message') || title.includes('comment') || title.includes('mentioned')) {
|
||||
return 'mentions';
|
||||
} else if (type.includes('staff_assigned') || type.includes('user') || title.includes('invited') || title.includes('followed')) {
|
||||
return 'invites';
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
// Filter notifications based on active filter
|
||||
const filteredNotifications = notifications.filter(notif => {
|
||||
if (activeFilter === 'all') return true;
|
||||
return categorizeByType(notif) === activeFilter;
|
||||
});
|
||||
|
||||
// Group by day
|
||||
const groupByDay = (notifList) => {
|
||||
const groups = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: []
|
||||
};
|
||||
|
||||
notifList.forEach(notif => {
|
||||
const date = new Date(notif.created_date);
|
||||
if (isToday(date)) {
|
||||
groups.today.push(notif);
|
||||
} else if (isYesterday(date)) {
|
||||
groups.yesterday.push(notif);
|
||||
} else if (isThisWeek(date)) {
|
||||
groups.thisWeek.push(notif);
|
||||
} else {
|
||||
groups.older.push(notif);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const groupedNotifications = groupByDay(filteredNotifications);
|
||||
|
||||
// Count by type
|
||||
const allCount = notifications.length;
|
||||
const mentionsCount = notifications.filter(n => categorizeByType(n) === 'mentions').length;
|
||||
const invitesCount = notifications.filter(n => categorizeByType(n) === 'invites').length;
|
||||
|
||||
const handleAction = (notification) => {
|
||||
// Mark as read when clicking
|
||||
if (!notification.is_read) {
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}
|
||||
|
||||
const entityType = notification.related_entity_type;
|
||||
const entityId = notification.related_entity_id;
|
||||
const activityType = notification.activity_type || '';
|
||||
|
||||
// Route based on entity type
|
||||
if (entityType === 'event' || activityType.includes('event') || activityType.includes('order')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EventDetail?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Events'));
|
||||
}
|
||||
} else if (entityType === 'task' || activityType.includes('task')) {
|
||||
navigate(createPageUrl('TaskBoard'));
|
||||
} else if (entityType === 'invoice' || activityType.includes('invoice')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`Invoices?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('Invoices'));
|
||||
}
|
||||
} else if (entityType === 'staff' || activityType.includes('staff')) {
|
||||
if (entityId) {
|
||||
navigate(createPageUrl(`EditStaff?id=${entityId}`));
|
||||
} else {
|
||||
navigate(createPageUrl('StaffDirectory'));
|
||||
}
|
||||
} else if (entityType === 'message' || activityType.includes('message')) {
|
||||
navigate(createPageUrl('Messages'));
|
||||
} else if (notification.action_link) {
|
||||
navigate(createPageUrl(notification.action_link));
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 300 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 300 }}
|
||||
transition={{ type: "spring", damping: 25 }}
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[440px] bg-white shadow-2xl z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-6 h-6 text-[#1C323E]" />
|
||||
<h2 className="text-xl font-bold text-[#1C323E]">Notifications</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-2 px-6 pb-4">
|
||||
<button
|
||||
onClick={() => setActiveFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'all'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
View all <span className="ml-1">{allCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('mentions')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'mentions'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Mentions <span className="ml-1">{mentionsCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter('invites')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeFilter === 'invites'
|
||||
? 'bg-blue-50 text-blue-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
Invites <span className="ml-1">{invitesCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<Bell className="w-16 h-16 text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">No notifications</h3>
|
||||
<p className="text-slate-600">You're all caught up!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TODAY */}
|
||||
{groupedNotifications.today.length > 0 && (
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">TODAY</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.today.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => handleAction(notification)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* YESTERDAY */}
|
||||
{groupedNotifications.yesterday.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">YESTERDAY</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.yesterday.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 relative">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* THIS WEEK */}
|
||||
{groupedNotifications.thisWeek.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">THIS WEEK</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.thisWeek.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-80 hover:opacity-100">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OLDER */}
|
||||
{groupedNotifications.older.length > 0 && (
|
||||
<div className="px-6 py-4 border-t border-slate-100">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">OLDER</h3>
|
||||
<div className="space-y-4">
|
||||
{groupedNotifications.older.map((notification) => {
|
||||
const Icon = iconMap[notification.icon_type] || AlertCircle;
|
||||
const isUnread = !notification.is_read;
|
||||
const notifDate = new Date(notification.created_date);
|
||||
|
||||
return (
|
||||
<div key={notification.id} className="flex gap-3 group hover:bg-slate-50 p-3 rounded-lg transition-all -mx-3 opacity-70 hover:opacity-100">
|
||||
{isUnread && (
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-2" />
|
||||
)}
|
||||
<div
|
||||
onClick={() => handleAction(notification)}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 cursor-pointer ${
|
||||
notification.icon_color === 'blue' ? 'bg-blue-100' :
|
||||
notification.icon_color === 'green' ? 'bg-green-100' :
|
||||
notification.icon_color === 'red' ? 'bg-red-100' :
|
||||
notification.icon_color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<Icon className={`w-5 h-5 ${
|
||||
notification.icon_color === 'blue' ? 'text-blue-600' :
|
||||
notification.icon_color === 'green' ? 'text-green-600' :
|
||||
notification.icon_color === 'red' ? 'text-red-600' :
|
||||
notification.icon_color === 'purple' ? 'text-purple-600' :
|
||||
'text-slate-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => notification.action_link && handleAction(notification)}
|
||||
>
|
||||
<div className={`flex items-start justify-between mb-1 ${notification.action_link ? 'cursor-pointer' : ''}`}>
|
||||
<p className="text-sm text-slate-900">
|
||||
<span className="font-semibold">{notification.title}</span> {notification.description}
|
||||
</p>
|
||||
<span className="text-xs text-slate-500 whitespace-nowrap ml-3">
|
||||
{formatDistanceToNow(notifDate, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{notification.action_link && (
|
||||
<span className="font-medium text-blue-600">{notification.action_label}</span>
|
||||
)}
|
||||
<span className="text-slate-500">{format(notifDate, 'MM/dd/yy • h:mm a')}</span>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsReadMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Mark as Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate({ id: notification.id });
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Sparkles className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-2">You're All Set! 🎉</h2>
|
||||
<p className="text-slate-500">Review your information before completing onboarding</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* Profile Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Profile Information</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<p className="text-slate-500">Name</p>
|
||||
<p className="font-medium">{profile.full_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Email</p>
|
||||
<p className="font-medium">{profile.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Position</p>
|
||||
<p className="font-medium">{profile.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-500">Location</p>
|
||||
<p className="font-medium">{profile.city}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Documents Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Documents Uploaded</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{documents.map((doc, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{doc.name}
|
||||
</Badge>
|
||||
))}
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-slate-500">No documents uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Training Summary */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<BookOpen className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Training Completed</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
{training.completed.length} training modules completed
|
||||
</p>
|
||||
{training.acknowledged && (
|
||||
<Badge className="mt-2 bg-green-500">Compliance Acknowledged</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-purple-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">What Happens Next?</h3>
|
||||
<ul className="space-y-2 text-sm text-slate-600">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>Your profile will be activated and available for shift assignments</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>You'll receive an email confirmation with your login credentials</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>Our team will review your documents within 24-48 hours</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span>You can start accepting shift invitations immediately</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} disabled={isSubmitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onComplete}
|
||||
disabled={isSubmitting}
|
||||
className="bg-gradient-to-r from-[#0A39DF] to-blue-600"
|
||||
>
|
||||
{isSubmitting ? "Creating Profile..." : "Complete Onboarding"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Document Upload</h2>
|
||||
<p className="text-sm text-slate-500">Upload required documents for compliance</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{requiredDocuments.map(doc => {
|
||||
const uploadedDoc = getUploadedDoc(doc.id);
|
||||
const isUploading = uploading[doc.id];
|
||||
|
||||
return (
|
||||
<Card key={doc.id} className={uploadedDoc ? "border-green-500 bg-green-50" : ""}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Label className="font-semibold">
|
||||
{doc.name}
|
||||
{doc.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
{uploadedDoc && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{doc.description}</p>
|
||||
|
||||
{uploadedDoc && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm text-slate-700">{uploadedDoc.name}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveDocument(doc.id)}
|
||||
className="ml-2 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={`upload-${doc.id}`}
|
||||
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg ${
|
||||
uploadedDoc
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-blue-100 text-blue-700 hover:bg-blue-200"
|
||||
} transition-colors`}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{isUploading ? "Uploading..." : uploadedDoc ? "Replace" : "Upload"}
|
||||
</label>
|
||||
<input
|
||||
id={`upload-${doc.id}`}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={(e) => handleFileUpload(doc.id, e.target.files[0])}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="bg-[#0A39DF]">
|
||||
Continue to Training
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Profile Setup</h2>
|
||||
<p className="text-sm text-slate-500">Tell us about yourself</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Personal Information */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
|
||||
<User className="w-4 h-4" />
|
||||
<span>Personal Information</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="full_name">Full Name *</Label>
|
||||
<Input
|
||||
id="full_name"
|
||||
value={profile.full_name}
|
||||
onChange={(e) => handleChange('full_name', e.target.value)}
|
||||
required
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
required
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Phone Number *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
required
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="city">City *</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={profile.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
required
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="address">Street Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={profile.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employment Details */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
<span>Employment Details</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="position">Position/Role *</Label>
|
||||
<Input
|
||||
id="position"
|
||||
value={profile.position}
|
||||
onChange={(e) => handleChange('position', e.target.value)}
|
||||
required
|
||||
placeholder="e.g., Server, Chef, Bartender"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="department">Department</Label>
|
||||
<Select value={profile.department} onValueChange={(value) => handleChange('department', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select department" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Operations">Operations</SelectItem>
|
||||
<SelectItem value="Kitchen">Kitchen</SelectItem>
|
||||
<SelectItem value="Service">Service</SelectItem>
|
||||
<SelectItem value="Bar">Bar</SelectItem>
|
||||
<SelectItem value="Events">Events</SelectItem>
|
||||
<SelectItem value="Catering">Catering</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="employment_type">Employment Type *</Label>
|
||||
<Select value={profile.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Full Time">Full Time</SelectItem>
|
||||
<SelectItem value="Part Time">Part Time</SelectItem>
|
||||
<SelectItem value="On call">On Call</SelectItem>
|
||||
<SelectItem value="Seasonal">Seasonal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="english_level">English Proficiency</Label>
|
||||
<Select value={profile.english_level} onValueChange={(value) => handleChange('english_level', value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Fluent">Fluent</SelectItem>
|
||||
<SelectItem value="Intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="Basic">Basic</SelectItem>
|
||||
<SelectItem value="None">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 mt-6">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>Work Location</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hub_location">Preferred Hub/Location</Label>
|
||||
<Input
|
||||
id="hub_location"
|
||||
value={profile.hub_location}
|
||||
onChange={(e) => handleChange('hub_location', e.target.value)}
|
||||
placeholder="e.g., Downtown SF, Bay Area"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" className="bg-[#0A39DF]">
|
||||
Continue to Documents
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 mb-2">Compliance Training</h2>
|
||||
<p className="text-sm text-slate-500">Complete required training modules to ensure readiness</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{trainingModules.map(module => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className={isComplete(module.id) ? "border-green-500 bg-green-50" : ""}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{isComplete(module.id) ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<Circle className="w-6 h-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{module.title}
|
||||
{module.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">{module.duration} · {module.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm text-slate-600 mb-3 ml-4 space-y-1">
|
||||
{module.topics.map((topic, idx) => (
|
||||
<li key={idx} className="list-disc">{topic}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isComplete(module.id) ? "outline" : "default"}
|
||||
onClick={() => handleModuleComplete(module.id)}
|
||||
className={isComplete(module.id) ? "" : "bg-[#0A39DF]"}
|
||||
>
|
||||
{isComplete(module.id) ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Completed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start Training
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allRequiredComplete && (
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
id="acknowledge"
|
||||
checked={training.acknowledged}
|
||||
onCheckedChange={handleAcknowledge}
|
||||
/>
|
||||
<label htmlFor="acknowledge" className="text-sm text-slate-700 cursor-pointer">
|
||||
I acknowledge that I have completed the required training modules and understand the
|
||||
policies, procedures, and safety guidelines outlined above. I agree to follow all
|
||||
company policies and maintain compliance standards.
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!allRequiredComplete || !training.acknowledged}
|
||||
className="bg-[#0A39DF]"
|
||||
>
|
||||
Complete Onboarding
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, Clock, DollarSign, Calendar, Users } from "lucide-react";
|
||||
import { format, differenceInHours } from "date-fns";
|
||||
|
||||
// Calculate if cancellation fee applies
|
||||
export const calculateCancellationFee = (eventDate, eventStartTime, assignedCount) => {
|
||||
const now = new Date();
|
||||
|
||||
// Combine event date and start time
|
||||
const eventDateTime = new Date(`${eventDate}T${eventStartTime || '00:00'}`);
|
||||
const hoursUntilEvent = differenceInHours(eventDateTime, now);
|
||||
|
||||
// Rule: 24+ hours = no fee, < 24 hours = 4-hour fee per worker
|
||||
const feeApplies = hoursUntilEvent < 24;
|
||||
const feeAmount = feeApplies ? assignedCount * 4 * 50 : 0; // Assuming $50/hour average
|
||||
|
||||
return {
|
||||
feeApplies,
|
||||
hoursUntilEvent,
|
||||
feeAmount,
|
||||
assignedCount
|
||||
};
|
||||
};
|
||||
|
||||
export default function CancellationFeeModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
event,
|
||||
isSubmitting
|
||||
}) {
|
||||
if (!event) return null;
|
||||
|
||||
const eventStartTime = event.shifts?.[0]?.roles?.[0]?.start_time || '09:00';
|
||||
const assignedCount = event.assigned_staff?.length || 0;
|
||||
const feeData = calculateCancellationFee(event.date, eventStartTime, assignedCount);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-red-700">
|
||||
Confirm Order Cancellation
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-600 mt-1">
|
||||
{feeData.feeApplies
|
||||
? "⚠️ Cancellation fee will apply"
|
||||
: "✅ No cancellation fee"
|
||||
}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Event Summary */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<h4 className="font-bold text-slate-900 mb-3">{event.event_name}</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span>{format(new Date(event.date), 'MMM d, yyyy')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<span>{eventStartTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<span>{assignedCount} Staff Assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Until Event */}
|
||||
<Alert className={feeData.feeApplies ? "bg-red-50 border-red-300" : "bg-green-50 border-green-300"}>
|
||||
<AlertDescription>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className={`w-5 h-5 ${feeData.feeApplies ? 'text-red-600' : 'text-green-600'}`} />
|
||||
<span className="font-bold text-slate-900">
|
||||
{feeData.hoursUntilEvent} hours until event
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700">
|
||||
{feeData.feeApplies
|
||||
? "Canceling within 24 hours triggers a 4-hour minimum fee per assigned worker."
|
||||
: "You're canceling more than 24 hours in advance - no penalty applies."
|
||||
}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Fee Breakdown */}
|
||||
{feeData.feeApplies && (
|
||||
<div className="bg-gradient-to-r from-red-50 to-orange-50 border-2 border-red-300 rounded-xl p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="w-5 h-5 text-red-600" />
|
||||
<h4 className="font-bold text-red-900">Cancellation Fee Breakdown</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
|
||||
<span className="text-sm text-slate-700">Assigned Staff</span>
|
||||
<span className="font-bold text-slate-900">{assignedCount} workers</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-white rounded-lg">
|
||||
<span className="text-sm text-slate-700">Minimum Charge</span>
|
||||
<span className="font-bold text-slate-900">4 hours each</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-red-100 rounded-lg border-2 border-red-300">
|
||||
<span className="font-bold text-red-900">Total Cancellation Fee</span>
|
||||
<span className="text-2xl font-bold text-red-700">
|
||||
${feeData.feeAmount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Text */}
|
||||
<Alert className="bg-yellow-50 border-yellow-300">
|
||||
<AlertDescription className="text-sm text-yellow-900">
|
||||
<strong>⚠️ This action cannot be undone.</strong> The vendor will be notified immediately,
|
||||
and all assigned staff will be released from this event.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Keep Order
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isSubmitting ? "Canceling..." : `Confirm Cancellation${feeData.feeApplies ? ` ($${feeData.feeAmount})` : ''}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Calendar, MapPin, Users, DollarSign, Clock, Building2, FileText, X, Star, ExternalLink, Edit3, User } from "lucide-react";
|
||||
import { format, parseISO, isValid } from "date-fns";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createPageUrl } from "@/utils";
|
||||
|
||||
const safeParseDate = (dateString) => {
|
||||
if (!dateString) return null;
|
||||
try {
|
||||
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
|
||||
return isValid(date) ? date : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
const safeFormatDate = (dateString, formatString) => {
|
||||
const date = safeParseDate(dateString);
|
||||
return date ? format(date, formatString) : '—';
|
||||
};
|
||||
|
||||
const convertTo12Hour = (time24) => {
|
||||
if (!time24) return "-";
|
||||
try {
|
||||
const [hours, minutes] = time24.split(':');
|
||||
const hour = parseInt(hours);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hour % 12 || 12;
|
||||
return `${hour12}:${minutes} ${ampm}`;
|
||||
} catch {
|
||||
return time24;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
'Draft': { bg: 'bg-slate-500', text: 'Draft' },
|
||||
'Pending': { bg: 'bg-amber-500', text: 'Pending' },
|
||||
'Partial Staffed': { bg: 'bg-orange-500', text: 'Partial Staffed' },
|
||||
'Fully Staffed': { bg: 'bg-emerald-500', text: 'Fully Staffed' },
|
||||
'Active': { bg: 'bg-blue-500', text: 'Active' },
|
||||
'Completed': { bg: 'bg-slate-400', text: 'Completed' },
|
||||
'Canceled': { bg: 'bg-red-500', text: 'Canceled' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { bg: 'bg-slate-400', text: status };
|
||||
|
||||
return (
|
||||
<Badge className={`${config.bg} text-white px-4 py-1.5 font-semibold`}>
|
||||
{config.text}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrderDetailModal({ open, onClose, order, onCancel }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: allStaff = [] } = useQuery({
|
||||
queryKey: ['staff-for-order-modal'],
|
||||
queryFn: () => base44.entities.Staff.list(),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
if (!order) return null;
|
||||
|
||||
const canEditOrder = (order) => {
|
||||
const eventDate = safeParseDate(order.date);
|
||||
const now = new Date();
|
||||
return order.status !== "Completed" &&
|
||||
order.status !== "Canceled" &&
|
||||
eventDate && eventDate > now;
|
||||
};
|
||||
|
||||
const canCancelOrder = (order) => {
|
||||
return order.status !== "Completed" && order.status !== "Canceled";
|
||||
};
|
||||
|
||||
const handleViewFullOrder = () => {
|
||||
navigate(createPageUrl(`EventDetail?id=${order.id}`));
|
||||
};
|
||||
|
||||
const handleEditOrder = () => {
|
||||
navigate(createPageUrl(`EditEvent?id=${order.id}`));
|
||||
};
|
||||
|
||||
const handleCancelOrder = () => {
|
||||
onClose();
|
||||
if (onCancel) {
|
||||
onCancel(order);
|
||||
}
|
||||
};
|
||||
|
||||
const assignedCount = order.assigned_staff?.length || 0;
|
||||
const requestedCount = order.requested || 0;
|
||||
const assignmentProgress = requestedCount > 0 ? Math.round((assignedCount / requestedCount) * 100) : 0;
|
||||
|
||||
// Get event times
|
||||
const firstShift = order.shifts?.[0];
|
||||
const rolesInFirstShift = firstShift?.roles || [];
|
||||
const startTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].start_time) : "-";
|
||||
const endTime = rolesInFirstShift.length > 0 ? convertTo12Hour(rolesInFirstShift[0].end_time) : "-";
|
||||
|
||||
// Get staff details
|
||||
const getStaffDetails = (staffId) => {
|
||||
return allStaff.find(s => s.id === staffId) || {};
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="border-b pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-900">{order.event_name}</DialogTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">Order Details & Information</p>
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Order Information */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Order Information</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Event Date</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{safeFormatDate(order.date, 'MMM dd, yyyy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{order.hub || order.event_location || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Staff Assigned</p>
|
||||
<p className="font-bold text-slate-900 text-sm">{assignedCount} / {requestedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Total Cost</p>
|
||||
<p className="font-bold text-slate-900 text-sm">${(order.total || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business & Time Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Building2 className="w-4 h-4 text-blue-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Business</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{order.business_name || "—"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-purple-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Time</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{startTime} - {endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Location & POC */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-4 h-4 text-emerald-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Event Location</p>
|
||||
</div>
|
||||
<p className="font-bold text-slate-900">{order.event_location || order.hub || "—"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<User className="w-4 h-4 text-indigo-600" />
|
||||
<p className="text-xs text-slate-500 font-semibold">Point of Contact</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-slate-900">{order.client_name || order.manager_name || "—"}</p>
|
||||
{order.client_phone && (
|
||||
<p className="text-xs text-slate-500 mt-1">{order.client_phone}</p>
|
||||
)}
|
||||
{order.client_email && (
|
||||
<p className="text-xs text-slate-500">{order.client_email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shifts & Roles */}
|
||||
{order.shifts && order.shifts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Shifts & Staff Requirements</h3>
|
||||
<div className="space-y-3">
|
||||
{order.shifts.map((shift, idx) => (
|
||||
<div key={idx} className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{shift.shift_name || `Shift ${idx + 1}`}</p>
|
||||
{shift.location && (
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1 mt-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{shift.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{shift.roles?.map((role, roleIdx) => (
|
||||
<div key={roleIdx} className="flex items-center justify-between bg-white rounded p-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{role.role}</p>
|
||||
<p className="text-xs text-slate-500">{role.department || "—"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Required</p>
|
||||
<p className="font-bold text-slate-900">{role.count || 0}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Time</p>
|
||||
<p className="font-medium text-slate-900 text-sm">
|
||||
{convertTo12Hour(role.start_time)} - {convertTo12Hour(role.end_time)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-500">Rate</p>
|
||||
<p className="font-bold text-emerald-600">${role.cost_per_hour}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assigned Staff */}
|
||||
{order.assigned_staff && order.assigned_staff.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Assigned Staff ({order.assigned_staff.length})</h3>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{order.assigned_staff.map((staff, idx) => {
|
||||
const staffDetails = getStaffDetails(staff.staff_id);
|
||||
const rating = staffDetails.rating || 0;
|
||||
const reliability = staffDetails.reliability_score || 0;
|
||||
const totalShifts = staffDetails.total_shifts || 0;
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between bg-white rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage
|
||||
src={staffDetails.profile_picture || staffDetails.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(staff.staff_name || 'Staff')}&background=10b981&color=fff&size=128`}
|
||||
alt={staff.staff_name}
|
||||
/>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{staff.staff_name}</p>
|
||||
<p className="text-sm text-slate-500">{staffDetails.position || staff.role || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span className="font-bold text-slate-900">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Rating</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-emerald-600">{reliability}%</p>
|
||||
<p className="text-xs text-slate-500">On-Time Arrival</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-blue-600">{totalShifts}</p>
|
||||
<p className="text-xs text-slate-500">Jobs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{order.notes && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Additional Notes</h3>
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<p className="text-slate-700 text-sm whitespace-pre-wrap">{order.notes}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleViewFullOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Full Order
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEditOrder(order) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleEditOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Edit Order
|
||||
</Button>
|
||||
)}
|
||||
{canCancelOrder(order) && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelOrder}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Cancel Order
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertTriangle, UserMinus, TrendingDown, CheckCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function OrderReductionAlert({
|
||||
originalRequested,
|
||||
newRequested,
|
||||
currentAssigned,
|
||||
onAutoUnassign,
|
||||
onManualUnassign,
|
||||
lowReliabilityStaff = []
|
||||
}) {
|
||||
const excessStaff = currentAssigned - newRequested;
|
||||
|
||||
if (excessStaff <= 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-orange-500 bg-orange-50 shadow-lg">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-100 to-red-50 border-b border-orange-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold text-orange-900">
|
||||
Order Size Reduction Detected
|
||||
</CardTitle>
|
||||
<p className="text-sm text-orange-700 mt-1">
|
||||
Client reduced headcount from {originalRequested} to {newRequested}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Alert className="bg-white border-orange-300">
|
||||
<AlertDescription className="text-slate-900">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingDown className="w-5 h-5 text-orange-600" />
|
||||
<span className="font-bold">Action Required:</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
You have <strong className="text-orange-700">{excessStaff} staff member{excessStaff !== 1 ? 's' : ''}</strong> assigned
|
||||
that exceed{excessStaff === 1 ? 's' : ''} the new request.
|
||||
You must unassign {excessStaff} worker{excessStaff !== 1 ? 's' : ''} to match the new headcount.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white border-2 border-slate-200 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">Original Request</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{originalRequested}</p>
|
||||
</div>
|
||||
<div className="bg-white border-2 border-orange-300 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-orange-600 mb-1">New Request</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{newRequested}</p>
|
||||
</div>
|
||||
<div className="bg-white border-2 border-red-300 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-red-600 mb-1">Must Remove</p>
|
||||
<p className="text-2xl font-bold text-red-700">{excessStaff}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={onManualUnassign}
|
||||
variant="outline"
|
||||
className="w-full border-2 border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
<UserMinus className="w-4 h-4 mr-2" />
|
||||
Manually Select Which Staff to Remove
|
||||
</Button>
|
||||
|
||||
{lowReliabilityStaff.length > 0 && (
|
||||
<Button
|
||||
onClick={onAutoUnassign}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Auto-Remove {excessStaff} Lowest Reliability Staff
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lowReliabilityStaff.length > 0 && (
|
||||
<div className="bg-white border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-xs font-bold text-slate-700 mb-3 uppercase">
|
||||
Suggested for Auto-Removal (Lowest Reliability):
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{lowReliabilityStaff.slice(0, excessStaff).map((staff, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 bg-red-50 rounded-lg border border-red-200">
|
||||
<span className="text-sm font-medium text-slate-900">{staff.name}</span>
|
||||
<Badge variant="outline" className="border-red-400 text-red-700">
|
||||
Reliability: {staff.reliability}%
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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 (
|
||||
<Badge
|
||||
className={`${status.color} ${sizeClasses[size]} font-semibold shadow-sm whitespace-nowrap flex items-center gap-1.5 ${className}`}
|
||||
title={status.description}
|
||||
>
|
||||
{showDot && (
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${status.dotColor} animate-pulse`} />
|
||||
)}
|
||||
{showIcon && <Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />}
|
||||
{status.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Utility to calculate order status based on current state
|
||||
export function calculateOrderStatus(event) {
|
||||
// Check explicit statuses first
|
||||
if (event.status === "Canceled" || event.status === "Cancelled") {
|
||||
return "Canceled";
|
||||
}
|
||||
|
||||
if (event.status === "Draft") {
|
||||
return "Draft";
|
||||
}
|
||||
|
||||
if (event.status === "Completed") {
|
||||
return "Completed";
|
||||
}
|
||||
|
||||
// Calculate status based on staffing
|
||||
const requested = event.requested || 0;
|
||||
const assigned = event.assigned_staff?.length || 0;
|
||||
|
||||
if (requested === 0) {
|
||||
return "Draft"; // No staff requested yet
|
||||
}
|
||||
|
||||
if (assigned === 0) {
|
||||
return "Pending"; // Awaiting assignment
|
||||
}
|
||||
|
||||
if (assigned < requested) {
|
||||
return "Partial"; // Partially staffed
|
||||
}
|
||||
|
||||
if (assigned >= requested) {
|
||||
return "Confirmed"; // Fully staffed
|
||||
}
|
||||
|
||||
return "Pending";
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
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 (
|
||||
<Card className="bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 border-2 border-red-300 shadow-xl">
|
||||
<CardHeader className="border-b border-red-200 bg-white/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl font-bold text-red-700 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
RAPID Order Assistant
|
||||
</CardTitle>
|
||||
<p className="text-xs text-red-600 mt-1">Emergency staffing in minutes</p>
|
||||
</div>
|
||||
<Badge className="bg-red-600 text-white font-bold text-sm px-4 py-2 shadow-md animate-pulse">
|
||||
URGENT
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6">
|
||||
{/* Chat Messages */}
|
||||
<div className="space-y-4 mb-6 max-h-[400px] overflow-y-auto">
|
||||
{conversation.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-500 to-orange-500 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-2">Need staff urgently?</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">Just describe what you need, I'll handle the rest</p>
|
||||
<div className="text-left max-w-md mx-auto space-y-2">
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||
<strong>Example:</strong> "We had a call out. Need 2 cooks ASAP"
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg border border-slate-200 text-xs text-slate-600">
|
||||
<strong>Example:</strong> "Emergency! Need bartender for tonight"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{conversation.map((msg, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-gradient-to-br from-blue-600 to-blue-700 text-white'
|
||||
: 'bg-white border-2 border-red-200'
|
||||
} rounded-2xl p-4 shadow-md`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-red-600">AI Assistant</span>
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-sm whitespace-pre-line ${msg.role === 'user' ? 'text-white' : 'text-slate-900'}`}>
|
||||
{msg.content}
|
||||
</p>
|
||||
|
||||
{msg.showConfirm && detectedOrder && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 p-3 bg-gradient-to-br from-slate-50 to-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Staff Needed</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.count} {detectedOrder.role}{detectedOrder.count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Location</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs col-span-2">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-slate-500">Time</p>
|
||||
<p className="font-bold text-slate-900">{detectedOrder.start_time} → {detectedOrder.end_time}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmOrder}
|
||||
disabled={createRapidOrderMutation.isPending}
|
||||
className="flex-1 bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{createRapidOrderMutation.isPending ? "Creating..." : "CONFIRM & SEND"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEditOrder}
|
||||
variant="outline"
|
||||
className="border-2 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{isProcessing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="bg-white border-2 border-red-200 rounded-2xl p-4 shadow-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
<span className="text-sm text-slate-600">Processing your request...</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-600 hover:from-red-700 hover:to-orange-700 text-white font-bold shadow-lg"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<strong>Tip:</strong> Include role, quantity, and urgency for fastest processing.
|
||||
AI will auto-detect your location and send to your preferred vendor.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, Send, Mic, Calendar, Clock, ArrowLeft, Users, MapPin, Edit2, CheckCircle } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function RapidOrderInterface({ onBack, onSubmit }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [parsedData, setParsedData] = useState(null);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const handleBack = () => {
|
||||
if (onBack) onBack();
|
||||
};
|
||||
|
||||
const examples = [
|
||||
{ text: "We had a call out. Need 2 cooks ASAP", color: "bg-blue-50 border-blue-200 text-blue-700" },
|
||||
{ text: "Need 5 bartenders ASAP until 5am", color: "bg-purple-50 border-purple-200 text-purple-700" },
|
||||
{ text: "Emergency! Need 3 servers right now till midnight", color: "bg-green-50 border-green-200 text-green-700" },
|
||||
];
|
||||
|
||||
const parseRapidMessage = (msg) => {
|
||||
// Extract count (numbers)
|
||||
const countMatch = msg.match(/(\d+)/);
|
||||
const count = countMatch ? parseInt(countMatch[1]) : 1;
|
||||
|
||||
// Extract role (common keywords)
|
||||
const roles = ['server', 'cook', 'chef', 'bartender', 'dishwasher', 'host', 'runner'];
|
||||
let role = 'staff';
|
||||
for (const r of roles) {
|
||||
if (msg.toLowerCase().includes(r)) {
|
||||
role = r + (count > 1 ? 's' : '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract time (until X, till X, by X)
|
||||
const timeMatch = msg.match(/until\s+(\d+(?::\d+)?\s*(?:am|pm)?)|till\s+(\d+(?::\d+)?\s*(?:am|pm)?)|by\s+(\d+(?::\d+)?\s*(?:am|pm)?)/i);
|
||||
const endTime = timeMatch ? (timeMatch[1] || timeMatch[2] || timeMatch[3]) : '11:59pm';
|
||||
|
||||
// Current time as start
|
||||
const now = new Date();
|
||||
const startTime = format(now, 'h:mm a');
|
||||
|
||||
return {
|
||||
count,
|
||||
role,
|
||||
startTime,
|
||||
endTime,
|
||||
location: "Client's location" // Default, can be auto-detected
|
||||
};
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) return;
|
||||
setIsProcessing(true);
|
||||
|
||||
// Parse the message
|
||||
const parsed = parseRapidMessage(message);
|
||||
setParsedData(parsed);
|
||||
setShowConfirmation(true);
|
||||
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onSubmit && parsedData) {
|
||||
onSubmit({
|
||||
rawMessage: message,
|
||||
orderType: 'rapid',
|
||||
...parsedData
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowConfirmation(false);
|
||||
setParsedData(null);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExampleClick = (exampleText) => {
|
||||
setMessage(exampleText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-lg border-2 border-red-200 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-red-500 to-orange-500 p-4 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" />
|
||||
RAPID Order
|
||||
</h2>
|
||||
<p className="text-red-100 text-xs">Emergency staffing in minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-red-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{format(new Date(), 'EEE, MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(), 'h:mm a')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-slate-900">Tell us what you need</h3>
|
||||
<Badge className="bg-red-500 text-white text-xs">URGENT</Badge>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-4">
|
||||
{/* Icon + Message */}
|
||||
<div className="text-center py-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-red-500 to-orange-500 rounded-xl flex items-center justify-center mx-auto mb-3 shadow-lg">
|
||||
<Zap className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h4 className="text-lg font-bold text-slate-900 mb-1">Need staff urgently?</h4>
|
||||
<p className="text-sm text-slate-600">Type or speak what you need. I'll handle the rest</p>
|
||||
</div>
|
||||
|
||||
{/* Example Prompts */}
|
||||
<div className="space-y-2">
|
||||
{examples.map((example, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleExampleClick(example.text)}
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-all hover:shadow-md text-sm ${example.color}`}
|
||||
>
|
||||
<span className="font-semibold">Example:</span> "{example.text}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
{showConfirmation && parsedData ? (
|
||||
<div className="space-y-4">
|
||||
{/* AI Confirmation Card */}
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 border-2 border-orange-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-bold text-red-600 uppercase mb-1">AI Assistant</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
Is this a RAPID ORDER for <strong>**{parsedData.count} {parsedData.role}**</strong> at <strong>**{parsedData.location}**</strong>?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<p className="text-xs text-slate-600">Start Time: <strong className="text-slate-900">{parsedData.startTime}</strong></p>
|
||||
<p className="text-xs text-slate-600">End Time: <strong className="text-slate-900">{parsedData.endTime}</strong></p>
|
||||
</div>
|
||||
|
||||
{/* Details Card */}
|
||||
<div className="bg-white border-2 border-blue-200 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Staff Needed</p>
|
||||
<p className="text-sm font-bold text-slate-900">{parsedData.count} {parsedData.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<MapPin className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Location</p>
|
||||
<p className="text-sm font-bold text-slate-900">{parsedData.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-blue-600 font-semibold uppercase">Time</p>
|
||||
<p className="text-sm font-bold text-slate-900">Start: {parsedData.startTime} | End: {parsedData.endTime}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 h-11 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-bold shadow-lg"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
CONFIRM & SEND
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="outline"
|
||||
className="h-11 px-6 border-2 border-slate-300 hover:border-slate-400"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-2" />
|
||||
EDIT
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder='Type or speak... (e.g., "Need 5 cooks ASAP until 5am")'
|
||||
rows={3}
|
||||
className="resize-none border-2 border-slate-200 focus:border-red-400 rounded-lg text-sm"
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-10 border-2 border-slate-300 hover:border-slate-400 text-sm"
|
||||
>
|
||||
<Mic className="w-4 h-4 mr-2" />
|
||||
Speak
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || isProcessing}
|
||||
className="flex-1 h-10 bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600 text-white font-semibold shadow-lg text-sm"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{isProcessing ? 'Processing...' : 'Send Message'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip */}
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-white text-xs font-bold">i</span>
|
||||
</div>
|
||||
<div className="text-xs text-blue-900">
|
||||
<span className="font-semibold">Tip:</span> Include role, quantity, and urgency for fastest processing. Optionally add end time like "until 5am" or "till midnight". AI will auto-detect your location and send to your preferred vendor with priority notification.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-2xl">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-900">Smart Assign (AI Assisted)</span>
|
||||
<p className="text-sm text-slate-600 font-normal mt-1">
|
||||
AI selected the best {countNeeded} {roleNeeded}{countNeeded > 1 ? 's' : ''} for this event
|
||||
</p>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isAnalyzing ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="font-bold text-lg text-slate-900 mb-2">Analyzing workforce...</h3>
|
||||
<p className="text-sm text-slate-600">AI is finding the optimal matches based on skills, ratings, and availability</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-blue-700 mb-1">Selected</p>
|
||||
<p className="text-2xl font-bold text-blue-900">{selectedWorkers.length}/{countNeeded}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-pink-50 border-2 border-purple-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Zap className="w-8 h-8 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-xs text-purple-700 mb-1">Avg Rating</p>
|
||||
<p className="text-2xl font-bold text-purple-900">
|
||||
{selectedWorkers.length > 0
|
||||
? (selectedWorkers.reduce((sum, w) => sum + (w.rating || 0), 0) / selectedWorkers.length).toFixed(1)
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-xs text-green-700 mb-1">Available</p>
|
||||
<p className="text-2xl font-bold text-green-900">{eligibleStaff.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* AI Recommendations */}
|
||||
{aiRecommendations && aiRecommendations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-slate-900">AI Recommendations</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={runSmartAnalysis}
|
||||
className="border-purple-300 hover:bg-purple-50"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Re-analyze
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{aiRecommendations.map((worker, idx) => {
|
||||
const isSelected = selectedWorkers.some(w => w.id === worker.id);
|
||||
const isOverLimit = selectedWorkers.length >= countNeeded && !isSelected;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={worker.id}
|
||||
className={`transition-all cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-gradient-to-br from-purple-50 to-indigo-50 border-2 border-purple-400 shadow-lg'
|
||||
: 'bg-white border border-slate-200 hover:border-purple-300 hover:shadow-md'
|
||||
} ${isOverLimit ? 'opacity-50' : ''}`}
|
||||
onClick={() => !isOverLimit && toggleWorker(worker)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="relative">
|
||||
<Avatar className="w-12 h-12 border-2 border-purple-300">
|
||||
<AvatarFallback className="bg-gradient-to-br from-purple-500 to-indigo-600 text-white font-bold">
|
||||
{worker.employee_name?.charAt(0) || 'W'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{idx === 0 && (
|
||||
<div className="absolute -top-1 -right-1 w-6 h-6 bg-yellow-500 rounded-full flex items-center justify-center shadow-md">
|
||||
<Award className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-bold text-slate-900">{worker.employee_name}</h4>
|
||||
{idx === 0 && (
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-xs">
|
||||
Top Pick
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{worker.position}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
|
||||
<span className="text-sm font-bold text-slate-900">{worker.rating?.toFixed(1) || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<TrendingUp className="w-4 h-4 text-green-600" />
|
||||
{worker.total_shifts || 0} shifts
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-slate-600">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
{worker.city || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{worker.ai_reason && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-2 mt-2">
|
||||
<p className="text-xs text-purple-900">
|
||||
<strong className="text-purple-700">AI Insight:</strong> {worker.ai_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{worker.ai_score && (
|
||||
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white font-bold">
|
||||
{Math.round(worker.ai_score)}/100
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && (
|
||||
<CheckCircle className="w-6 h-6 text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-12 h-12 mx-auto mb-3 text-slate-400" />
|
||||
<p className="text-slate-600">No eligible staff found for this role</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => assignMutation.mutate()}
|
||||
disabled={selectedWorkers.length === 0 || assignMutation.isPending}
|
||||
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold"
|
||||
>
|
||||
{assignMutation.isPending ? "Assigning..." : `Assign ${selectedWorkers.length} Workers`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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 (
|
||||
<Card className="bg-white border-2 border-slate-200 hover:border-blue-300 hover:shadow-lg transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-bold text-lg text-slate-900">{event.event_name}</h3>
|
||||
{event.is_rapid && (
|
||||
<Badge className="bg-red-600 text-white font-bold text-xs">
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
RAPID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{assignment.role}</p>
|
||||
</div>
|
||||
<Badge className={`border-2 font-semibold ${getStatusColor()}`}>
|
||||
{assignment.assignment_status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Date</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "TBD"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Time</p>
|
||||
<p className="font-semibold text-slate-900">
|
||||
{assignment.scheduled_start ? format(new Date(assignment.scheduled_start), "h:mm a") : "ASAP"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm col-span-2">
|
||||
<MapPin className="w-4 h-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Location</p>
|
||||
<p className="font-semibold text-slate-900">{event.event_location || event.hub}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shift Details */}
|
||||
{event.shifts?.[0] && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Info className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-bold text-blue-900">Shift Details</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-slate-700">
|
||||
{event.shifts[0].uniform_type && (
|
||||
<p><strong>Attire:</strong> {event.shifts[0].uniform_type}</p>
|
||||
)}
|
||||
{event.addons?.meal_provided && (
|
||||
<p><strong>Meal:</strong> Provided</p>
|
||||
)}
|
||||
{event.notes && (
|
||||
<p><strong>Notes:</strong> {event.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{assignment.assignment_status === "Pending" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => confirmMutation.mutate("Confirmed")}
|
||||
disabled={confirmMutation.isPending}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white font-bold"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Accept Shift
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => confirmMutation.mutate("Cancelled")}
|
||||
disabled={confirmMutation.isPending}
|
||||
variant="outline"
|
||||
className="flex-1 border-2 border-red-300 text-red-600 hover:bg-red-50 font-bold"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assignment.assignment_status === "Confirmed" && (
|
||||
<div className="bg-green-50 border-2 border-green-300 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-green-700">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-bold text-sm">You're confirmed for this shift!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Added AvatarImage
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Users, Calendar, Package, DollarSign, FileText, BarChart3,
|
||||
Shield, Building2, Briefcase, Info, ExternalLink
|
||||
} from "lucide-react";
|
||||
|
||||
const ROLE_TEMPLATES = {
|
||||
admin: { name: "Administrator", color: "bg-red-100 text-red-700" },
|
||||
procurement: { name: "Procurement Manager", color: "bg-purple-100 text-purple-700" },
|
||||
operator: { name: "Operator", color: "bg-blue-100 text-blue-700" },
|
||||
sector: { name: "Sector Manager", color: "bg-cyan-100 text-cyan-700" },
|
||||
client: { name: "Client", color: "bg-green-100 text-green-700" },
|
||||
vendor: { name: "Vendor Partner", color: "bg-amber-100 text-amber-700" },
|
||||
workforce: { name: "Workforce Member", color: "bg-slate-100 text-slate-700" }
|
||||
};
|
||||
|
||||
const ROLE_PERMISSIONS = {
|
||||
admin: [
|
||||
{ id: "system_admin", icon: Shield, name: "System Administration", description: "Full access to all system settings and user management", defaultEnabled: true },
|
||||
{ id: "enterprise_mgmt", icon: Building2, name: "Enterprise Management", description: "Create and manage enterprises, sectors, and partners", defaultEnabled: true },
|
||||
{ id: "vendor_oversight", icon: Package, name: "Vendor Oversight", description: "Approve/suspend vendors and manage all vendor relationships", defaultEnabled: true },
|
||||
{ id: "financial_admin", icon: DollarSign, name: "Financial Administration", description: "Access all financial data and process payments", defaultEnabled: true },
|
||||
{ id: "reports_admin", icon: BarChart3, name: "Admin Reports", description: "Generate and export all system reports", defaultEnabled: true },
|
||||
],
|
||||
procurement: [
|
||||
{ id: "vendor_view", icon: Package, name: "View All Vendors", description: "Access complete vendor directory and profiles", defaultEnabled: true },
|
||||
{ id: "vendor_onboard", icon: Users, name: "Onboard Vendors", description: "Add new vendors to the platform", defaultEnabled: true },
|
||||
{ id: "vendor_compliance", icon: Shield, name: "Vendor Compliance Review", description: "Review and approve vendor compliance documents", defaultEnabled: true },
|
||||
{ id: "rate_cards", icon: DollarSign, name: "Rate Card Management", description: "Create, edit, and approve vendor rate cards", defaultEnabled: true },
|
||||
{ id: "vendor_performance", icon: BarChart3, name: "Vendor Performance Analytics", description: "View scorecards and KPI reports", defaultEnabled: true },
|
||||
{ id: "order_oversight", icon: Calendar, name: "Order Oversight", description: "View and manage all orders across sectors", defaultEnabled: false },
|
||||
],
|
||||
operator: [
|
||||
{ id: "enterprise_events", icon: Calendar, name: "Enterprise Event Management", description: "Create and manage events across your enterprise", defaultEnabled: true },
|
||||
{ id: "sector_mgmt", icon: Building2, name: "Sector Management", description: "Configure and manage your sectors", defaultEnabled: true },
|
||||
{ id: "staff_mgmt", icon: Users, name: "Workforce Management", description: "Assign and manage staff across enterprise", defaultEnabled: true },
|
||||
{ id: "event_financials", icon: DollarSign, name: "Event Financials", description: "View costs and billing for all events", defaultEnabled: true },
|
||||
{ id: "approve_events", icon: Shield, name: "Approve Events", description: "Approve event requests from sectors", defaultEnabled: false },
|
||||
{ id: "enterprise_reports", icon: BarChart3, name: "Enterprise Reports", description: "Generate analytics for your enterprise", defaultEnabled: true },
|
||||
],
|
||||
sector: [
|
||||
{ id: "sector_events", icon: Calendar, name: "Sector Event Management", description: "Create and manage events at your location", defaultEnabled: true },
|
||||
{ id: "location_staff", icon: Users, name: "Location Staff Management", description: "Schedule and manage staff at your sector", defaultEnabled: true },
|
||||
{ id: "timesheet_approval", icon: FileText, name: "Timesheet Approval", description: "Review and approve staff timesheets", defaultEnabled: true },
|
||||
{ id: "vendor_rates", icon: DollarSign, name: "View Vendor Rates", description: "Access rate cards for approved vendors", defaultEnabled: true },
|
||||
{ id: "event_costs", icon: BarChart3, name: "Event Cost Visibility", description: "View billing for your events", defaultEnabled: false },
|
||||
],
|
||||
client: [
|
||||
{ id: "create_orders", icon: Calendar, name: "Create Orders", description: "Request staffing for events", defaultEnabled: true },
|
||||
{ id: "view_orders", icon: FileText, name: "View My Orders", description: "Track your event orders", defaultEnabled: true },
|
||||
{ id: "vendor_selection", icon: Package, name: "Vendor Selection", description: "View and request specific vendors", defaultEnabled: true },
|
||||
{ id: "staff_review", icon: Users, name: "Staff Review", description: "Rate staff and request changes", defaultEnabled: true },
|
||||
{ id: "invoices", icon: DollarSign, name: "Billing & Invoices", description: "View and download invoices", defaultEnabled: true },
|
||||
{ id: "spend_analytics", icon: BarChart3, name: "Spend Analytics", description: "View your spending trends", defaultEnabled: false },
|
||||
],
|
||||
vendor: [
|
||||
{ id: "order_fulfillment", icon: Calendar, name: "Order Fulfillment", description: "Accept and manage assigned orders", defaultEnabled: true },
|
||||
{ id: "my_workforce", icon: Users, name: "My Workforce", description: "Manage your staff members", defaultEnabled: true },
|
||||
{ id: "staff_compliance", icon: Shield, name: "Staff Compliance", description: "Track certifications and background checks", defaultEnabled: true },
|
||||
{ id: "my_rates", icon: DollarSign, name: "Rate Management", description: "View and propose rate cards", defaultEnabled: true },
|
||||
{ id: "my_invoices", icon: FileText, name: "Invoices & Payments", description: "Create and track invoices", defaultEnabled: true },
|
||||
{ id: "performance", icon: BarChart3, name: "Performance Dashboard", description: "View your scorecard and metrics", defaultEnabled: false },
|
||||
],
|
||||
workforce: [
|
||||
{ id: "my_schedule", icon: Calendar, name: "View My Schedule", description: "See upcoming shifts and assignments", defaultEnabled: true },
|
||||
{ id: "clock_inout", icon: FileText, name: "Clock In/Out", description: "Record shift start and end times", defaultEnabled: true },
|
||||
{ id: "my_profile", icon: Users, name: "My Profile", description: "Update contact info and availability", defaultEnabled: true },
|
||||
{ id: "upload_certs", icon: Shield, name: "Upload Certifications", description: "Add certificates and licenses", defaultEnabled: true },
|
||||
{ id: "earnings", icon: DollarSign, name: "View Earnings", description: "See pay, hours, and payment history", defaultEnabled: true },
|
||||
{ id: "performance_stats", icon: BarChart3, name: "My Performance", description: "View ratings and reliability metrics", defaultEnabled: false },
|
||||
]
|
||||
};
|
||||
|
||||
export default function UserPermissionsModal({ user, open, onClose, onSave, isSaving }) {
|
||||
const [selectedRole, setSelectedRole] = useState(user?.user_role || "client");
|
||||
const [permissions, setPermissions] = useState({});
|
||||
|
||||
// Initialize permissions when user or role changes
|
||||
useEffect(() => {
|
||||
if (user && open) {
|
||||
const rolePerms = ROLE_PERMISSIONS[user.user_role || "client"] || [];
|
||||
const initialPerms = {};
|
||||
rolePerms.forEach(perm => {
|
||||
initialPerms[perm.id] = perm.defaultEnabled;
|
||||
});
|
||||
setPermissions(initialPerms);
|
||||
setSelectedRole(user.user_role || "client");
|
||||
}
|
||||
}, [user, open]);
|
||||
|
||||
// Update permissions when role changes
|
||||
useEffect(() => {
|
||||
const rolePerms = ROLE_PERMISSIONS[selectedRole] || [];
|
||||
const newPerms = {};
|
||||
rolePerms.forEach(perm => {
|
||||
newPerms[perm.id] = perm.defaultEnabled;
|
||||
});
|
||||
setPermissions(newPerms);
|
||||
}, [selectedRole]);
|
||||
|
||||
const handleToggle = (permId) => {
|
||||
setPermissions(prev => ({
|
||||
...prev,
|
||||
[permId]: !prev[permId]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...user,
|
||||
user_role: selectedRole,
|
||||
permissions: Object.keys(permissions).filter(key => permissions[key])
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const roleTemplate = ROLE_TEMPLATES[selectedRole];
|
||||
const rolePermissions = ROLE_PERMISSIONS[selectedRole] || [];
|
||||
const userInitial = user.full_name?.charAt(0).toUpperCase() || user.email?.charAt(0).toUpperCase() || "U";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-xl font-bold text-slate-900">User Permissions</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex items-center justify-between py-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-14 h-14 border-2 border-slate-200">
|
||||
<AvatarImage src={user.profile_picture} alt={user.full_name} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-lg">
|
||||
{userInitial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg text-slate-900">{user.full_name}</h3>
|
||||
<p className="text-sm text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="link" className="text-[#0A39DF] text-sm">
|
||||
View profile <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert className="bg-blue-50 border-blue-200">
|
||||
<Info className="w-4 h-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-800 text-sm">
|
||||
Permission list will change when you select a different user group
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* User Group Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">User Group</label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger className="border-slate-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ROLE_TEMPLATES).map(([key, role]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={role.color + " text-xs"}>{role.name}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Permissions List */}
|
||||
<div className="space-y-3 py-4">
|
||||
{rolePermissions.map((perm) => {
|
||||
const Icon = perm.icon;
|
||||
return (
|
||||
<div
|
||||
key={perm.id}
|
||||
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm border border-slate-200">
|
||||
<Icon className="w-5 h-5 text-[#0A39DF]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-sm text-slate-900">{perm.name}</h4>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{perm.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={permissions[perm.id] || false}
|
||||
onCheckedChange={() => handleToggle(perm.id)}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-slate-200">
|
||||
<Button onClick={handleSave} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 px-6">
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Shield, Download, Upload, Save, AlertCircle } from "lucide-react";
|
||||
|
||||
export default function COIViewer({ vendor, onClose }) {
|
||||
const coverageLines = [
|
||||
{
|
||||
name: "General Liability",
|
||||
expires: "11/20/2025",
|
||||
status: "Non-Compliant",
|
||||
statusColor: "bg-red-100 text-red-700"
|
||||
},
|
||||
{
|
||||
name: "Automobile Liability",
|
||||
expires: "03/30/2025",
|
||||
status: "Non-Compliant",
|
||||
statusColor: "bg-red-100 text-red-700"
|
||||
},
|
||||
{
|
||||
name: "Workers Compensation",
|
||||
expires: "11/15/2025",
|
||||
status: "Non-Compliant",
|
||||
statusColor: "bg-red-100 text-red-700"
|
||||
},
|
||||
{
|
||||
name: "Property",
|
||||
expires: "",
|
||||
status: "Non-Compliant",
|
||||
statusColor: "bg-red-100 text-red-700"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-5 h-5 text-[#0A39DF]" />
|
||||
<h3 className="font-bold text-lg text-[#1C323E]">Policy & Agent</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Last Reviewed: 07/25/2025, 12:24 PM</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload COI
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Lines Covered by Agent */}
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base text-[#1C323E]">Lines Covered by Agent</CardTitle>
|
||||
<p className="text-sm text-blue-600 mt-2">
|
||||
Legendary Event Staffing & Entertainment, LLC (Vendor as an Agent)
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Uploaded 07/23/2025, 01:20 PM</p>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
{coverageLines.map((line, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-[#1C323E] text-sm">{line.name}</p>
|
||||
{line.expires && (
|
||||
<p className="text-xs text-slate-600 mt-1">Expires {line.expires}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={line.statusColor}>
|
||||
{line.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="link" className="text-blue-600 text-sm p-0 h-auto mt-4">
|
||||
Show Iliana contact info
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Non-Compliant Notes */}
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-amber-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base text-[#1C323E] flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-amber-600" />
|
||||
Non-Compliant Notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{/* General Liability */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E] mb-3">General Liability</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Please confirm aggregate limit applies on a per location basis on the certificate and/or by uploading additional documentation.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Waiver of Subrogation</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Primary and Non-Contributory</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Confirm on certificate Contractual Liability is not excluded.</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Confirm on the certificate that severability of interest is included.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Property */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E] mb-3">Property</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Please submit proof of coverage.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Additional Coverage */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E] mb-3">Additional Coverage</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Waiver of Subrogation applies to: South Bay Construction and Development I, LLC and South Bay Development Company, and their Employees, Agents</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Automobile Liability */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E] mb-3">Automobile Liability</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Waiver of Subrogation</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Workers Compensation */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-[#1C323E] mb-3">Workers Compensation</h4>
|
||||
<ul className="space-y-2 text-sm text-slate-700">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-amber-600 mt-1">•</span>
|
||||
<span>Waiver of Subrogation</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
4 Items Non-Compliant
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save & Send for Review
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Building2,
|
||||
Award,
|
||||
Shield,
|
||||
FileText,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Users,
|
||||
MapPin,
|
||||
Mail,
|
||||
Phone,
|
||||
Edit,
|
||||
Download,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Target,
|
||||
Upload,
|
||||
ArrowLeft // Added for viewer components
|
||||
} from "lucide-react";
|
||||
|
||||
// COI Viewer Component
|
||||
const COIViewer = ({ vendor, onClose }) => (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100 flex-row items-center justify-between py-4 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
<CardTitle className="text-base text-[#1C323E]">
|
||||
Certificate of Insurance for {vendor.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download COI
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 h-[500px] overflow-y-auto">
|
||||
{/* Mock PDF Viewer / Content */}
|
||||
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg p-4 text-gray-500 border border-gray-200">
|
||||
<FileText className="w-16 h-16 mb-4" />
|
||||
<p className="text-lg font-semibold mb-2">COI Document Preview</p>
|
||||
<p className="text-sm text-center">This is a placeholder for the Certificate of Insurance document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.</p>
|
||||
<p className="text-xs mt-4">Vendor Name: <span className="font-medium text-slate-700">{vendor.name}</span></p>
|
||||
<p className="text-xs">Policy Number: <span className="font-medium text-slate-700">ABC-123456789</span></p>
|
||||
<p className="text-xs">Expiration Date: <span className="font-medium text-slate-700">11/20/2025</span></p>
|
||||
<div className="mt-6 w-full max-w-lg h-[300px] bg-white border border-gray-300 flex items-center justify-center text-gray-400 text-sm rounded-md shadow-sm">
|
||||
[PDF Viewer Embed / Document Image Placeholder]
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// W9 Form Viewer Component
|
||||
const W9FormViewer = ({ vendor, onClose }) => (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100 flex-row items-center justify-between py-4 px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</Button>
|
||||
<CardTitle className="text-base text-[#1C323E]">
|
||||
W-9 Form for {vendor.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download W-9
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 h-[500px] overflow-y-auto">
|
||||
{/* Mock PDF Viewer / Content */}
|
||||
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg p-4 text-gray-500 border border-gray-200">
|
||||
<FileText className="w-16 h-16 mb-4" />
|
||||
<p className="text-lg font-semibold mb-2">W-9 Form Document Preview</p>
|
||||
<p className="text-sm text-center">This is a placeholder for the W-9 Form document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.</p>
|
||||
<p className="text-xs mt-4">Vendor Name: <span className="font-medium text-slate-700">{vendor.name}</span></p>
|
||||
<p className="text-xs">Tax ID: <span className="font-medium text-slate-700">XX-XXXXXXX</span></p>
|
||||
<p className="text-xs">Last Updated: <span className="font-medium text-slate-700">07/23/2025</span></p>
|
||||
<div className="mt-6 w-full max-w-lg h-[300px] bg-white border border-gray-300 flex items-center justify-center text-gray-400 text-sm rounded-md shadow-sm">
|
||||
[PDF Viewer Embed / Document Image Placeholder]
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default function VendorDetailModal({ vendor, open, onClose, onEdit }) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [showCOI, setShowCOI] = useState(false);
|
||||
const [showW9, setShowW9] = useState(false);
|
||||
|
||||
if (!vendor) return null;
|
||||
|
||||
// Mock compliance data - would come from backend
|
||||
const complianceData = {
|
||||
overall: vendor.name === "Legendary Event Staffing" ? 98 : 95,
|
||||
coi: { status: "valid", expires: "11/20/2025" },
|
||||
w9: { status: "valid", lastUpdated: "07/23/2025" },
|
||||
backgroundChecks: { status: "valid", percentage: 100 },
|
||||
insurance: { status: vendor.name === "Legendary Event Staffing" ? "valid" : "expiring", expires: "03/30/2025" }
|
||||
};
|
||||
|
||||
const performanceData = {
|
||||
overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
|
||||
fillRate: vendor.fillRate,
|
||||
onTimeRate: vendor.onTimeRate,
|
||||
billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
|
||||
clientSatisfaction: vendor.csat,
|
||||
reliability: vendor.name === "Legendary Event Staffing" ? 98 : 91,
|
||||
avgHourlyRate: vendor.name === "Legendary Event Staffing" ? 23.50 : vendor.name === "Instawork" ? 22.00 : 21.00,
|
||||
vendorFeePercentage: vendor.name === "Legendary Event Staffing" ? 19.6 : vendor.name === "Instawork" ? 20.5 : 22.0,
|
||||
employeeWage: vendor.name === "Legendary Event Staffing" ? 18.50 : 17.00,
|
||||
totalEmployees: vendor.employees,
|
||||
monthlySpend: vendor.spend
|
||||
};
|
||||
|
||||
const documents = [
|
||||
{ name: "COI (Certificate of Insurance)", status: complianceData.coi.status, lastUpdated: "2 days ago", type: "coi" },
|
||||
{ name: "W9 Forms", status: complianceData.w9.status, lastUpdated: "2 days ago", type: "w9" },
|
||||
{ name: "Contracts", status: "active", lastUpdated: "2 days ago", type: "contract" },
|
||||
{ name: "ESG Certification", status: "valid", lastUpdated: "2 days ago", type: "esg" },
|
||||
{ name: "Policies", status: "updated", lastUpdated: "2 days ago", type: "policy" },
|
||||
{ name: "Forms & Templates", status: "available", lastUpdated: "2 days ago", type: "forms" }
|
||||
];
|
||||
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 97) return "text-green-600";
|
||||
if (score >= 90) return "text-blue-600";
|
||||
if (score >= 80) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const colors = {
|
||||
valid: "bg-green-100 text-green-700",
|
||||
active: "bg-blue-100 text-blue-700",
|
||||
updated: "bg-blue-100 text-blue-700",
|
||||
available: "bg-slate-100 text-slate-700",
|
||||
expiring: "bg-yellow-100 text-yellow-700",
|
||||
expired: "bg-red-100 text-red-700"
|
||||
};
|
||||
return colors[status] || "bg-gray-100 text-gray-700";
|
||||
};
|
||||
|
||||
const getScoreBadgeColor = (score) => {
|
||||
if (score >= 95) return "bg-green-100 text-green-700 border-green-200";
|
||||
if (score >= 90) return "bg-blue-100 text-blue-700 border-blue-200";
|
||||
if (score >= 85) return "bg-amber-100 text-amber-700 border-amber-200";
|
||||
return "bg-red-100 text-red-700 border-red-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="p-0">
|
||||
<div className="flex items-start justify-between px-6 pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center">
|
||||
<Building2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">{vendor.name}</DialogTitle>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Badge className="bg-blue-100 text-blue-700">
|
||||
{vendor.region}
|
||||
</Badge>
|
||||
<Badge variant="outline">{vendor.specialty}</Badge>
|
||||
{vendor.softwareType === "platform" && (
|
||||
<Badge className="bg-green-100 text-green-700">✅ Full Platform</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "building" && (
|
||||
<Badge className="bg-blue-100 text-blue-700">⚙️ Building Platform</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => onEdit(vendor)}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<div className="border-b border-slate-200 bg-white px-6">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => { setActiveTab("overview"); setShowCOI(false); setShowW9(false); }}
|
||||
className={`px-4 py-3 text-sm font-medium transition-all ${
|
||||
activeTab === "overview"
|
||||
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab("compliance"); setShowCOI(false); setShowW9(false); }}
|
||||
className={`px-4 py-3 text-sm font-medium transition-all ${
|
||||
activeTab === "compliance"
|
||||
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
Compliance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab("performance"); setShowCOI(false); setShowW9(false); }}
|
||||
className={`px-4 py-3 text-sm font-medium transition-all ${
|
||||
activeTab === "performance"
|
||||
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
Performance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab("documents"); setShowCOI(false); setShowW9(false); }}
|
||||
className={`px-4 py-3 text-sm font-medium transition-all ${
|
||||
activeTab === "documents"
|
||||
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
|
||||
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="overflow-y-auto max-h-[calc(90vh-180px)] bg-slate-50 p-6">
|
||||
{/* Key Metrics Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Award className="w-5 h-5 text-amber-600" />
|
||||
<span className={`text-2xl font-bold ${performanceData.overallScore === 'A+' ? 'text-green-600' : 'text-blue-600'}`}>
|
||||
{performanceData.overallScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium">Overall Score</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Shield className="w-5 h-5 text-green-600" />
|
||||
<span className={`text-2xl font-bold ${getScoreColor(complianceData.overall)}`}>
|
||||
{complianceData.overall}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium">Compliance</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<DollarSign className="w-5 h-5 text-blue-600" />
|
||||
<span className={`text-2xl font-bold ${getScoreColor(performanceData.billingAccuracy)}`}>
|
||||
{performanceData.billingAccuracy}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium">Billing Accuracy</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-emerald-600" />
|
||||
<span className={`text-2xl font-bold ${getScoreColor(performanceData.fillRate)}`}>
|
||||
{performanceData.fillRate}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 font-medium">Fill Rate</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab Content */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Company Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Total Staff</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-semibold">{vendor.employees.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Region</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-semibold">{vendor.region}, {vendor.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Email</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-semibold text-sm">contact@{vendor.name.toLowerCase().replace(/\s+/g, '')}.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Phone</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-semibold">(555) 123-4567</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Technology Stack</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{vendor.softwareType === "platform" && (
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
✅ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "building" && (
|
||||
<Badge className="bg-blue-100 text-blue-700">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "partial" && (
|
||||
<Badge className="bg-yellow-100 text-yellow-700">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "traditional" && (
|
||||
<Badge className="bg-gray-100 text-gray-700">
|
||||
❌ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compliance Tab Content */}
|
||||
{activeTab === "compliance" && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-green-50 to-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-green-600" />
|
||||
Compliance Status
|
||||
</CardTitle>
|
||||
<Badge className="bg-green-100 text-green-700 text-lg px-4 py-1">
|
||||
{complianceData.overall}%
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-slate-900">COI</p>
|
||||
<Badge className={getStatusBadge(complianceData.coi.status)}>
|
||||
{complianceData.coi.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Expires: {complianceData.coi.expires}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-slate-900">W-9 Form</p>
|
||||
<Badge className={getStatusBadge(complianceData.w9.status)}>
|
||||
{complianceData.w9.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Updated: {complianceData.w9.lastUpdated}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-slate-900">Background Checks</p>
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
{complianceData.backgroundChecks.percentage}%
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">All staff verified</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-slate-900">Insurance</p>
|
||||
<Badge className={getStatusBadge(complianceData.insurance.status)}>
|
||||
{complianceData.insurance.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Expires: {complianceData.insurance.expires}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Tab Content */}
|
||||
{activeTab === "performance" && (
|
||||
<div className="space-y-6">
|
||||
{/* Company Size & Financial Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="border-slate-200 bg-gradient-to-br from-blue-50 to-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Total Employees</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.totalEmployees.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Available workforce</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-gradient-to-br from-emerald-50 to-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Monthly Spend</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.monthlySpend}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Average monthly cost</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200 bg-gradient-to-br from-purple-50 to-white">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Award className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Performance Grade</p>
|
||||
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.overallScore}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">Overall rating</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rates & Fees Breakdown */}
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5 text-[#0A39DF]" />
|
||||
Rates & Fee Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Average Hourly Rate</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-[#0A39DF]">${performanceData.avgHourlyRate.toFixed(2)}</p>
|
||||
<p className="text-sm text-slate-600">/hr</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-2">Client billing rate</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Employee Wage</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-emerald-600">${performanceData.employeeWage.toFixed(2)}</p>
|
||||
<p className="text-sm text-slate-600">/hr</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-2">Worker take-home rate</p>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Vendor Fee</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="text-3xl font-bold text-purple-600">{performanceData.vendorFeePercentage}%</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 mt-2">${(performanceData.avgHourlyRate - performanceData.employeeWage).toFixed(2)}/hr markup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual breakdown */}
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm font-semibold text-[#1C323E] mb-3">Cost Breakdown</p>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 bg-slate-200 rounded-full h-8 overflow-hidden flex">
|
||||
<div
|
||||
className="bg-emerald-500 flex items-center justify-center text-xs font-semibold text-white"
|
||||
style={{ width: `${(performanceData.employeeWage / performanceData.avgHourlyRate) * 100}%` }}
|
||||
>
|
||||
${performanceData.employeeWage}
|
||||
</div>
|
||||
<div
|
||||
className="bg-purple-500 flex items-center justify-center text-xs font-semibold text-white"
|
||||
style={{ width: `${((performanceData.avgHourlyRate - performanceData.employeeWage) / performanceData.avgHourlyRate) * 100}%` }}
|
||||
>
|
||||
${(performanceData.avgHourlyRate - performanceData.employeeWage).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded"></div>
|
||||
<span>Employee Wage ({((performanceData.employeeWage / performanceData.avgHourlyRate) * 100).toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded"></div>
|
||||
<span>Vendor Fee ({performanceData.vendorFeePercentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Metrics Grid */}
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#0A39DF]" />
|
||||
Key Performance Indicators
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Fill Rate", value: performanceData.fillRate, icon: Target },
|
||||
{ label: "On-Time Rate", value: performanceData.onTimeRate, icon: Clock },
|
||||
{ label: "Billing Accuracy", value: performanceData.billingAccuracy, icon: DollarSign },
|
||||
{ label: "Client Satisfaction", value: (performanceData.clientSatisfaction * 20).toFixed(0), icon: Award, suffix: "", display: `${performanceData.clientSatisfaction}/5.0` },
|
||||
{ label: "Reliability Score", value: performanceData.reliability, icon: Shield },
|
||||
{ label: "Compliance", value: complianceData.overall, icon: CheckCircle2 },
|
||||
].map((metric, idx) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<div key={idx} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Icon className="w-5 h-5 text-slate-400" />
|
||||
<Badge className={getScoreBadgeColor(metric.value)}>
|
||||
{metric.display || `${metric.value}${metric.suffix || '%'}`}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-700">{metric.label}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab Content */}
|
||||
{activeTab === "documents" && (
|
||||
<div className="space-y-4">
|
||||
{showCOI ? (
|
||||
<COIViewer vendor={vendor} onClose={() => setShowCOI(false)} />
|
||||
) : showW9 ? (
|
||||
<W9FormViewer vendor={vendor} onClose={() => setShowW9(false)} />
|
||||
) : (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[#0A39DF]" />
|
||||
Document Center
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{documents.map((doc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-4 border border-slate-200 rounded-lg hover:border-[#0A39DF] hover:shadow-md transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
if (doc.type === "coi") setShowCOI(true);
|
||||
else if (doc.type === "w9") setShowW9(true);
|
||||
// Add handlers for other document types as needed
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-6 h-6 text-[#0A39DF]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[#1C323E] text-sm">{doc.name}</p>
|
||||
<p className="text-xs text-slate-500">Last updated: {doc.lastUpdated}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className={getStatusBadge(doc.status)}>
|
||||
{doc.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-slate-600 hover:text-[#0A39DF]"
|
||||
onClick={(e) => {
|
||||
// Prevent the parent div's onClick from firing when download button is clicked
|
||||
e.stopPropagation();
|
||||
// Implement actual download logic here
|
||||
console.log(`Downloading ${doc.name}`);
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Award, Shield, DollarSign, TrendingUp, Users, MapPin } from "lucide-react";
|
||||
|
||||
export default function VendorHoverCard({ vendor, children }) {
|
||||
// Mock performance data - would come from backend
|
||||
const performanceData = {
|
||||
overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
|
||||
compliance: vendor.name === "Legendary Event Staffing" ? 98 : 95,
|
||||
billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
|
||||
fillRate: vendor.name === "Legendary Event Staffing" ? 97 : 92
|
||||
};
|
||||
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 97) return "text-green-600";
|
||||
if (score >= 90) return "text-blue-600";
|
||||
if (score >= 80) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96 p-0" side="right" align="start">
|
||||
<div className="bg-gradient-to-br from-slate-50 to-white p-4 border-b border-slate-200">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-[#1C323E]">{vendor.name}</h3>
|
||||
<p className="text-sm text-slate-600">{vendor.specialty}</p>
|
||||
</div>
|
||||
<Badge className={`${performanceData.overallScore === 'A+' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'} font-bold text-lg px-3 py-1`}>
|
||||
{performanceData.overallScore}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span>{vendor.region}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{vendor.employees.toLocaleString()} staff</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Compliance */}
|
||||
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-4 h-4 text-green-600" />
|
||||
<span className="text-xs font-medium text-slate-600">Compliance</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${getScoreColor(performanceData.compliance)}`}>
|
||||
{performanceData.compliance}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Billing Accuracy */}
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-medium text-slate-600">Billing Accuracy</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${getScoreColor(performanceData.billingAccuracy)}`}>
|
||||
{performanceData.billingAccuracy}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fill Rate */}
|
||||
<div className="bg-emerald-50 rounded-lg p-3 border border-emerald-200 col-span-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-xs font-medium text-slate-600">Fill Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className={`text-2xl font-bold ${getScoreColor(performanceData.fillRate)}`}>
|
||||
{performanceData.fillRate}%
|
||||
</p>
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${performanceData.fillRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Software Badge */}
|
||||
<div className="pt-3 border-t border-slate-200">
|
||||
<p className="text-xs text-slate-500 mb-2">Technology Stack</p>
|
||||
{vendor.softwareType === "platform" && (
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
✅ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "building" && (
|
||||
<Badge className="bg-blue-100 text-blue-700">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "partial" && (
|
||||
<Badge className="bg-yellow-100 text-yellow-700">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "traditional" && (
|
||||
<Badge className="bg-gray-100 text-gray-700">
|
||||
❌ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 text-center pt-2 border-t border-slate-200">
|
||||
Click for full details
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Award, Shield, DollarSign, TrendingUp, Users, MapPin, Building2, Phone, Mail, Briefcase, Hash } from "lucide-react";
|
||||
|
||||
export default function VendorScoreHoverCard({ vendor, children }) {
|
||||
// Safety checks for vendor data
|
||||
if (!vendor) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const getScoreColor = (score) => {
|
||||
if (!score) return "text-slate-400";
|
||||
if (score >= 95) return "text-green-600";
|
||||
if (score >= 90) return "text-blue-600";
|
||||
if (score >= 85) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getPerformanceGrade = (fillRate) => {
|
||||
if (!fillRate) return { grade: "N/A", color: "bg-slate-400" };
|
||||
if (fillRate >= 97) return { grade: "A+", color: "bg-green-600" };
|
||||
if (fillRate >= 95) return { grade: "A", color: "bg-green-500" };
|
||||
if (fillRate >= 90) return { grade: "B+", color: "bg-blue-500" };
|
||||
if (fillRate >= 85) return { grade: "B", color: "bg-yellow-500" };
|
||||
return { grade: "C", color: "bg-orange-500" };
|
||||
};
|
||||
|
||||
const performance = getPerformanceGrade(vendor.fillRate);
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-[450px] p-0" side="right" align="start">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] p-5 text-white">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 bg-white rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-[#0A39DF]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{vendor.name || vendor.legal_name || "Vendor"}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="bg-white/20 border-white/40 text-white font-mono text-xs">
|
||||
<Hash className="w-3 h-3 mr-1" />
|
||||
{vendor.vendorNumber || vendor.vendor_number || "N/A"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-white/80 mt-1">{vendor.specialty || "General Services"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${performance.color} text-white font-bold text-lg px-3 py-1`}>
|
||||
{performance.grade}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="bg-white/10 rounded-lg p-2">
|
||||
<p className="text-xs text-white/70">Monthly Spend</p>
|
||||
<p className="text-lg font-bold">{vendor.spend || "N/A"}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg p-2">
|
||||
<p className="text-xs text-white/70">Total Staff</p>
|
||||
<p className="text-lg font-bold">{vendor.employees ? vendor.employees.toLocaleString() : "N/A"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Information */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-3">Company Information</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{vendor.region || "N/A"}</p>
|
||||
<p className="text-xs text-slate-500">{vendor.state || "N/A"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Briefcase className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{vendor.specialty || "N/A"}</p>
|
||||
<p className="text-xs text-slate-500">Primary Service</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">{vendor.primary_contact_phone || "(555) 123-4567"}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-4 h-4 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">{vendor.primary_contact_email || `contact@${(vendor.name || "vendor").toLowerCase().replace(/\s+/g, '').replace(/[()&]/g, '')}.com`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technology Stack */}
|
||||
{vendor.software && (
|
||||
<div className="pt-3 border-t border-slate-200">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-2">Technology</h4>
|
||||
{vendor.softwareType === "platform" && (
|
||||
<Badge className="bg-green-100 text-green-700 font-medium">
|
||||
✅ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "building" && (
|
||||
<Badge className="bg-blue-100 text-blue-700 font-medium">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "partial" && (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 font-medium">
|
||||
⚙️ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
{vendor.softwareType === "traditional" && (
|
||||
<Badge className="bg-gray-100 text-gray-700 font-medium">
|
||||
❌ {vendor.software}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{(vendor.fillRate || vendor.onTimeRate || vendor.csat || vendor.employees) && (
|
||||
<div className="pt-3 border-t border-slate-200">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-3">Performance Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Fill Rate */}
|
||||
{vendor.fillRate && (
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-600" />
|
||||
<span className="text-xs font-medium text-slate-600">Fill Rate</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${getScoreColor(vendor.fillRate)}`}>
|
||||
{vendor.fillRate}%
|
||||
</p>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${vendor.fillRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* On-Time Rate */}
|
||||
{vendor.onTimeRate && (
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-xs font-medium text-slate-600">On-Time</span>
|
||||
</div>
|
||||
<p className={`text-2xl font-bold ${getScoreColor(vendor.onTimeRate)}`}>
|
||||
{vendor.onTimeRate}%
|
||||
</p>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${vendor.onTimeRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer Satisfaction */}
|
||||
{vendor.csat && (
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Award className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-xs font-medium text-slate-600">CSAT Score</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{vendor.csat}/5.0
|
||||
</p>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-500 to-amber-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(vendor.csat / 5) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workforce */}
|
||||
{vendor.employees && (
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-xs font-medium text-slate-600">Workforce</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-600">
|
||||
{vendor.employees.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Active staff</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-blue-50 p-3 border-t border-blue-200">
|
||||
<p className="text-xs text-center text-slate-600">
|
||||
Hover over vendor name in any table to see details • Click for full profile
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FileText, Download, Upload, Save, X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function W9FormViewer({ vendor, onClose }) {
|
||||
const [formData, setFormData] = useState({
|
||||
entity_name: vendor?.business_name || "",
|
||||
business_name: "",
|
||||
tax_classification: "",
|
||||
llc_classification: "",
|
||||
has_foreign_partners: false,
|
||||
exempt_payee_code: "",
|
||||
fatca_code: "",
|
||||
address: "",
|
||||
city_state_zip: "",
|
||||
account_numbers: "",
|
||||
ssn_part1: "",
|
||||
ssn_part2: "",
|
||||
ssn_part3: "",
|
||||
ein_part1: "",
|
||||
ein_part2: "",
|
||||
tin_type: "ssn",
|
||||
signature: "",
|
||||
date: ""
|
||||
});
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Save logic here
|
||||
console.log("Saving W9 form:", formData);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="w-5 h-5 text-[#0A39DF]" />
|
||||
<h3 className="font-bold text-lg text-[#1C323E]">Form W-9 (Rev. March 2024)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Request for Taxpayer Identification Number and Certification</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Signed
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Before you begin notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold text-blue-900 mb-1">Before you begin</p>
|
||||
<p className="text-xs text-blue-700">Give form to the requester. Do not send to the IRS.</p>
|
||||
</div>
|
||||
|
||||
{/* Line 1 & 2 - Names */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">1. Name of entity/individual *</Label>
|
||||
<p className="text-xs text-slate-500 mb-2">For a sole proprietor or disregarded entity, enter the owner's name on line 1</p>
|
||||
<Input
|
||||
value={formData.entity_name}
|
||||
onChange={(e) => handleChange('entity_name', e.target.value)}
|
||||
placeholder="Enter name as shown on your tax return"
|
||||
className="font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">2. Business name/disregarded entity name, if different from above</Label>
|
||||
<Input
|
||||
value={formData.business_name}
|
||||
onChange={(e) => handleChange('business_name', e.target.value)}
|
||||
placeholder="Enter business or DBA name (if applicable)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line 3a - Tax Classification */}
|
||||
<div className="border border-slate-200 rounded-lg p-4 bg-slate-50">
|
||||
<Label className="text-sm font-semibold mb-3 block">3a. Federal tax classification (Check only ONE box)</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "individual"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'individual')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">Individual/sole proprietor</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "c_corp"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'c_corp')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">C corporation</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "s_corp"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 's_corp')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">S corporation</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "partnership"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'partnership')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">Partnership</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "trust"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'trust')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">Trust/estate</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 col-span-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "llc"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'llc')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">LLC</Label>
|
||||
<span className="text-xs text-slate-500 mx-2">Enter tax classification:</span>
|
||||
<Input
|
||||
value={formData.llc_classification}
|
||||
onChange={(e) => handleChange('llc_classification', e.target.value)}
|
||||
placeholder="C, S, or P"
|
||||
className="w-20 h-8"
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.tax_classification === "other"}
|
||||
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'other')}
|
||||
/>
|
||||
<Label className="text-sm font-normal cursor-pointer">Other (see instructions)</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line 3b */}
|
||||
<div className="mt-4 pt-4 border-t border-slate-200">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
checked={formData.has_foreign_partners}
|
||||
onCheckedChange={(checked) => handleChange('has_foreign_partners', checked)}
|
||||
/>
|
||||
<div>
|
||||
<Label className="text-sm font-semibold cursor-pointer">3b. Foreign partners, owners, or beneficiaries</Label>
|
||||
<p className="text-xs text-slate-500 mt-1">Check if you have any foreign partners, owners, or beneficiaries</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line 4 - Exemptions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">4. Exempt payee code (if any)</Label>
|
||||
<Input
|
||||
value={formData.exempt_payee_code}
|
||||
onChange={(e) => handleChange('exempt_payee_code', e.target.value)}
|
||||
placeholder="See instructions"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Exemption from FATCA reporting code (if any)</Label>
|
||||
<Input
|
||||
value={formData.fatca_code}
|
||||
onChange={(e) => handleChange('fatca_code', e.target.value)}
|
||||
placeholder="Applies to accounts outside US"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lines 5-7 - Address */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">5. Address (number, street, and apt. or suite no.)</Label>
|
||||
<Input
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
placeholder="Enter street address"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">6. City, state, and ZIP code</Label>
|
||||
<Input
|
||||
value={formData.city_state_zip}
|
||||
onChange={(e) => handleChange('city_state_zip', e.target.value)}
|
||||
placeholder="City, State ZIP"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">7. List account number(s) here (optional)</Label>
|
||||
<Input
|
||||
value={formData.account_numbers}
|
||||
onChange={(e) => handleChange('account_numbers', e.target.value)}
|
||||
placeholder="Account numbers"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Part I - TIN */}
|
||||
<div className="border-2 border-slate-300 rounded-lg p-4 bg-white">
|
||||
<h3 className="font-bold text-sm mb-4">Part I - Taxpayer Identification Number (TIN)</h3>
|
||||
<p className="text-xs text-slate-600 mb-4">
|
||||
Enter your TIN in the appropriate box. The TIN provided must match the name given on line 1 to avoid backup withholding.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* SSN */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold mb-2 block">Social security number</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={formData.ssn_part1}
|
||||
onChange={(e) => handleChange('ssn_part1', e.target.value)}
|
||||
maxLength={3}
|
||||
placeholder="XXX"
|
||||
className="w-20 text-center font-mono"
|
||||
disabled={formData.tin_type === "ein"}
|
||||
/>
|
||||
<span className="text-lg">–</span>
|
||||
<Input
|
||||
value={formData.ssn_part2}
|
||||
onChange={(e) => handleChange('ssn_part2', e.target.value)}
|
||||
maxLength={2}
|
||||
placeholder="XX"
|
||||
className="w-16 text-center font-mono"
|
||||
disabled={formData.tin_type === "ein"}
|
||||
/>
|
||||
<span className="text-lg">–</span>
|
||||
<Input
|
||||
value={formData.ssn_part3}
|
||||
onChange={(e) => handleChange('ssn_part3', e.target.value)}
|
||||
maxLength={4}
|
||||
placeholder="XXXX"
|
||||
className="w-24 text-center font-mono"
|
||||
disabled={formData.tin_type === "ein"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm font-semibold text-slate-500">OR</div>
|
||||
|
||||
{/* EIN */}
|
||||
<div>
|
||||
<Label className="text-sm font-semibold mb-2 block">Employer identification number</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={formData.ein_part1}
|
||||
onChange={(e) => handleChange('ein_part1', e.target.value)}
|
||||
maxLength={2}
|
||||
placeholder="XX"
|
||||
className="w-16 text-center font-mono"
|
||||
disabled={formData.tin_type === "ssn"}
|
||||
/>
|
||||
<span className="text-lg">–</span>
|
||||
<Input
|
||||
value={formData.ein_part2}
|
||||
onChange={(e) => handleChange('ein_part2', e.target.value)}
|
||||
maxLength={7}
|
||||
placeholder="XXXXXXX"
|
||||
className="w-32 text-center font-mono"
|
||||
disabled={formData.tin_type === "ssn"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Part II - Certification */}
|
||||
<div className="border-2 border-slate-300 rounded-lg p-4 bg-yellow-50">
|
||||
<h3 className="font-bold text-sm mb-3">Part II - Certification</h3>
|
||||
<div className="text-xs text-slate-700 space-y-2 mb-4">
|
||||
<p className="font-semibold">Under penalties of perjury, I certify that:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 pl-2">
|
||||
<li>The number shown on this form is my correct taxpayer identification number (or I am waiting for a number to be issued to me); and</li>
|
||||
<li>I am not subject to backup withholding because (a) I am exempt from backup withholding, or (b) I have not been notified by the IRS that I am subject to backup withholding as a result of a failure to report all interest or dividends, or (c) the IRS has notified me that I am no longer subject to backup withholding; and</li>
|
||||
<li>I am a U.S. citizen or other U.S. person (defined below); and</li>
|
||||
<li>The FATCA code(s) entered on this form (if any) indicating that I am exempt from FATCA reporting is correct.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Signature of U.S. person *</Label>
|
||||
<Input
|
||||
value={formData.signature}
|
||||
onChange={(e) => handleChange('signature', e.target.value)}
|
||||
placeholder="Sign here"
|
||||
className="mt-1 italic"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">Date *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => handleChange('date', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Form W-9 (Rev. 3-2024)
|
||||
</Badge>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90" onClick={handleSave}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save W-9 Form
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Sparkles, DollarSign } from "lucide-react";
|
||||
|
||||
const APPROVED_BASE_RATES = {
|
||||
"FoodBuy": {
|
||||
"Banquet Captain": 47.76, "Barback": 36.13, "Barista": 43.11, "Busser": 39.23, "BW Bartender": 41.15,
|
||||
"Cashier/Standworker": 38.05, "Cook": 44.58, "Dinning Attendant": 41.56, "Dishwasher/ Steward": 38.38,
|
||||
"Executive Chef": 70.60, "FOH Cafe Attendant": 41.56, "Full Bartender": 47.76, "Grill Cook": 44.58,
|
||||
"Host/Hostess/Greeter": 41.56, "Internal Support": 41.56, "Lead Cook": 52.00, "Line Cook": 44.58,
|
||||
"Premium Server": 47.76, "Prep Cook": 37.98, "Receiver": 40.01, "Server": 41.56, "Sous Chef": 59.75,
|
||||
"Warehouse Worker": 41.15, "Baker": 44.58, "Janitor": 38.38, "Mixologist": 71.30, "Utilities": 38.38,
|
||||
"Scullery": 38.38, "Runner": 39.23, "Pantry Cook": 44.58, "Supervisor": 47.76, "Steward": 38.38, "Steward Supervisor": 41.15
|
||||
},
|
||||
"Aramark": {
|
||||
"Banquet Captain": 46.37, "Barback": 33.11, "Barista": 36.87, "Busser": 33.11, "BW Bartender": 36.12,
|
||||
"Cashier/Standworker": 33.11, "Cook": 36.12, "Dinning Attendant": 34.62, "Dishwasher/ Steward": 33.11,
|
||||
"Executive Chef": 76.76, "FOH Cafe Attendant": 34.62, "Full Bartender": 45.15, "Grill Cook": 36.12,
|
||||
"Host/Hostess/Greeter": 34.62, "Internal Support": 37.63, "Lead Cook": 52.68, "Line Cook": 36.12,
|
||||
"Premium Server": 40.64, "Prep Cook": 34.62, "Receiver": 34.62, "Server": 34.62, "Sous Chef": 60.20,
|
||||
"Warehouse Worker": 34.62, "Baker": 45.15, "Janitor": 34.62, "Mixologist": 60.20, "Utilities": 33.11,
|
||||
"Scullery": 33.11, "Runner": 33.11, "Pantry Cook": 36.12, "Supervisor": 45.15, "Steward": 33.11, "Steward Supervisor": 34.10
|
||||
}
|
||||
};
|
||||
|
||||
export default function RateCardModal({ isOpen, onClose, onSave, editingCard = null }) {
|
||||
const [cardName, setCardName] = useState("");
|
||||
const [baseRateBook, setBaseRateBook] = useState("FoodBuy");
|
||||
const [discountPercent, setDiscountPercent] = useState(0);
|
||||
|
||||
// Reset form when modal opens/closes or editingCard changes
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCardName(editingCard?.name || "");
|
||||
setBaseRateBook(editingCard?.baseBook || "FoodBuy");
|
||||
setDiscountPercent(editingCard?.discount || 0);
|
||||
}
|
||||
}, [isOpen, editingCard]);
|
||||
|
||||
const baseRates = APPROVED_BASE_RATES[baseRateBook] || {};
|
||||
const positions = Object.keys(baseRates);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const rates = Object.values(baseRates);
|
||||
const avgBase = rates.length > 0 ? rates.reduce((a, b) => a + b, 0) / rates.length : 0;
|
||||
const avgNew = avgBase * (1 - discountPercent / 100);
|
||||
const totalSavings = rates.reduce((sum, r) => sum + (r * discountPercent / 100), 0);
|
||||
return { avgBase, avgNew, totalSavings, count: rates.length };
|
||||
}, [baseRates, discountPercent]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!cardName.trim()) return;
|
||||
|
||||
const discountedRates = {};
|
||||
Object.entries(baseRates).forEach(([pos, rate]) => {
|
||||
discountedRates[pos] = Math.round(rate * (1 - discountPercent / 100) * 100) / 100;
|
||||
});
|
||||
|
||||
onSave({
|
||||
name: cardName,
|
||||
baseBook: baseRateBook,
|
||||
discount: discountPercent,
|
||||
rates: discountedRates
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-xl">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{editingCard ? "Edit Rate Card" : "Create Custom Rate Card"}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Build a custom rate card by selecting an approved rate book as your base and applying your competitive discount
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-6 py-4">
|
||||
{/* Step 1: Name */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center text-white text-xs font-bold">1</div>
|
||||
<h3 className="font-semibold text-slate-900">
|
||||
{editingCard ? "Rename Rate Card" : "Name Your Rate Card"}
|
||||
</h3>
|
||||
</div>
|
||||
<Input
|
||||
value={cardName}
|
||||
onChange={(e) => setCardName(e.target.value)}
|
||||
placeholder="e.g., Google Campus, Meta HQ, Salesforce Tower..."
|
||||
className="bg-white text-lg font-medium"
|
||||
autoFocus={!!editingCard}
|
||||
/>
|
||||
{editingCard && editingCard.name !== cardName && cardName.trim() && (
|
||||
<p className="text-sm text-green-600 mt-2 flex items-center gap-1">
|
||||
<span>✓</span> Will rename from "{editingCard.name}" to "{cardName}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Base Rates */}
|
||||
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 bg-amber-500 rounded-full flex items-center justify-center text-white text-xs font-bold">2</div>
|
||||
<h3 className="font-semibold text-slate-900">Choose Approved Base Rates</h3>
|
||||
</div>
|
||||
<Select value={baseRateBook} onValueChange={setBaseRateBook}>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FoodBuy">FoodBuy Approved Rates</SelectItem>
|
||||
<SelectItem value="Aramark">Aramark Approved Rates</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-amber-700 mt-2">
|
||||
Using {baseRateBook} as your baseline • Avg Rate: ${stats.avgBase.toFixed(2)}/hr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Discount */}
|
||||
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">3</div>
|
||||
<h3 className="font-semibold text-slate-900">Apply Your Discount</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={[discountPercent]}
|
||||
onValueChange={([val]) => setDiscountPercent(val)}
|
||||
min={0}
|
||||
max={25}
|
||||
step={0.5}
|
||||
className="[&_[role=slider]]:bg-green-500 [&_[role=slider]]:border-green-600"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-6 py-3 bg-white border-2 border-green-400 rounded-xl text-center min-w-[100px]">
|
||||
<span className="text-2xl font-bold text-green-600">% {discountPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 text-sm">
|
||||
<span className="text-slate-600">
|
||||
<span className="text-slate-400">↘</span> New Avg Rate: <strong className="text-slate-900">${stats.avgNew.toFixed(2)}/hr</strong>
|
||||
</span>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span className="text-slate-600">
|
||||
Total Savings: <strong className="text-green-600">${stats.totalSavings.toFixed(2)}/position</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Preview */}
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="bg-slate-50 px-5 py-3 border-b border-slate-200 flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-slate-600" />
|
||||
<h3 className="font-semibold text-slate-900">Rate Preview ({stats.count} positions)</h3>
|
||||
</div>
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 sticky top-0">
|
||||
<tr className="text-left text-slate-600">
|
||||
<th className="px-5 py-2 font-medium">Position</th>
|
||||
<th className="px-5 py-2 font-medium text-right">Base Rate</th>
|
||||
<th className="px-5 py-2 font-medium text-right">Your Rate</th>
|
||||
<th className="px-5 py-2 font-medium text-right">Savings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((position) => {
|
||||
const baseRate = baseRates[position];
|
||||
const newRate = baseRate * (1 - discountPercent / 100);
|
||||
const savings = baseRate - newRate;
|
||||
return (
|
||||
<tr key={position} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-5 py-3 font-medium text-slate-900">{position}</td>
|
||||
<td className="px-5 py-3 text-right text-slate-500">${baseRate.toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-green-600">${newRate.toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-md text-xs font-medium">
|
||||
-${savings.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!cardName.trim()}
|
||||
className="bg-gradient-to-r from-purple-500 to-indigo-600 hover:from-purple-600 hover:to-indigo-700 text-white"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
{editingCard ? "Save Changes" : "Create Rate Card"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, TrendingUp, Users, Star, Heart, AlertTriangle, ArrowUp } from "lucide-react";
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ReportInsightsBanner from "./ReportInsightsBanner";
|
||||
|
||||
export default function ClientTrendsReport({ events, invoices, userRole = 'admin' }) {
|
||||
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" });
|
||||
};
|
||||
|
||||
// Churn risk
|
||||
const lowEngagementClients = topClients.filter(c => c.bookings < 3);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Banner */}
|
||||
<ReportInsightsBanner userRole={userRole} reportType="clients" />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Client Intelligence</h2>
|
||||
<p className="text-sm text-slate-500">Satisfaction, retention & growth opportunities</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Retention Alert */}
|
||||
{lowEngagementClients.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-red-800">{lowEngagementClients.length} clients at churn risk</span>
|
||||
<p className="text-xs text-red-600">Less than 3 bookings this period</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
View List
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart className="w-5 h-5 text-green-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-green-800">{topClients.filter(c => c.bookings >= 5).length} loyal clients</span>
|
||||
<p className="text-xs text-green-600">5+ bookings — eligible for rewards</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="border-green-300 text-green-700 hover:bg-green-100">
|
||||
Send Thank You
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Clients</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Active this period</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${avgSatisfaction >= 4.5 ? 'border-l-green-500' : avgSatisfaction >= 4 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Satisfaction</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}</p>
|
||||
<div className="flex gap-0.5">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star key={i} className={`w-3 h-3 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${avgSatisfaction >= 4.5 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{avgSatisfaction >= 4.5 ? '✓ Excellent' : 'Room to improve'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${parseFloat(repeatRate) >= 40 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Repeat Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
|
||||
<p className="text-xs text-slate-500 mt-1">${(topClients.reduce((s, c) => s + c.revenue, 0) * 0.4).toLocaleString()} from repeats</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
|
||||
<p className="text-sm font-medium text-purple-900 mt-1">
|
||||
{lowEngagementClients.length > 2
|
||||
? `Re-engage ${lowEngagementClients.length} low-activity clients with special offers`
|
||||
: parseFloat(repeatRate) < 40
|
||||
? 'Launch loyalty program to boost repeat bookings by 25%'
|
||||
: 'Top 3 clients ready for annual contract — lock in 12-month deals'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly Booking Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Booking Trend Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={monthlyBookings}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="bookings" stroke="#0A39DF" strokeWidth={2} name="Bookings" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Clients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Clients by Bookings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={topClients} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis dataKey="name" type="category" width={150} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="bookings" fill="#0A39DF" name="Bookings" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Client List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Client Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{topClients.map((client, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{client.name}</p>
|
||||
<p className="text-sm text-slate-500">{client.bookings} bookings</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-semibold">
|
||||
${client.revenue.toLocaleString()}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Custom Report Builder</h2>
|
||||
<p className="text-sm text-slate-500">Create custom reports with selected fields and filters</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Configuration Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Report Name</Label>
|
||||
<Input
|
||||
value={reportConfig.name}
|
||||
onChange={(e) => setReportConfig(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g., Monthly Performance Report"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Data Source</Label>
|
||||
<Select
|
||||
value={reportConfig.dataSource}
|
||||
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dataSource: value, fields: [] }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="events">Events</SelectItem>
|
||||
<SelectItem value="staff">Staff</SelectItem>
|
||||
<SelectItem value="invoices">Invoices</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Date Range</Label>
|
||||
<Select
|
||||
value={reportConfig.dateRange}
|
||||
onValueChange={(value) => setReportConfig(prev => ({ ...prev, dateRange: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Select Fields to Include</Label>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto border border-slate-200 rounded-lg p-3">
|
||||
{availableFields.map(field => (
|
||||
<div key={field} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={field}
|
||||
checked={reportConfig.fields.includes(field)}
|
||||
onCheckedChange={() => handleFieldToggle(field)}
|
||||
/>
|
||||
<Label htmlFor={field} className="cursor-pointer text-sm">
|
||||
{field.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview Panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Report Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{reportConfig.name && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Report Name</Label>
|
||||
<p className="font-semibold text-slate-900">{reportConfig.name}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500">Data Source</Label>
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{reportConfig.dataSource.charAt(0).toUpperCase() + reportConfig.dataSource.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{reportConfig.fields.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-500 mb-2 block">Selected Fields ({reportConfig.fields.length})</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reportConfig.fields.map(field => (
|
||||
<Badge key={field} className="bg-blue-100 text-blue-700">
|
||||
{field.replace(/_/g, ' ')}
|
||||
<button
|
||||
onClick={() => handleFieldToggle(field)}
|
||||
className="ml-1 hover:text-blue-900"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<Button
|
||||
onClick={handleGenerateReport}
|
||||
className="w-full bg-[#0A39DF]"
|
||||
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export as CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExportJSON}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!reportConfig.name || reportConfig.fields.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export as JSON
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Saved Report Templates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Templates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Staff Performance Summary",
|
||||
dataSource: "staff",
|
||||
dateRange: "30",
|
||||
fields: ['employee_name', 'position', 'rating', 'reliability_score'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Staff Performance
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Event Cost Summary",
|
||||
dataSource: "events",
|
||||
dateRange: "90",
|
||||
fields: ['event_name', 'business_name', 'date', 'total', 'status'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Event Costs
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
onClick={() => setReportConfig({
|
||||
name: "Invoice Status Report",
|
||||
dataSource: "invoices",
|
||||
dateRange: "30",
|
||||
fields: ['invoice_number', 'business_name', 'amount', 'status', 'due_date'],
|
||||
filters: [],
|
||||
groupBy: "",
|
||||
})}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Invoice Status
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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, Target, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ReportInsightsBanner from "./ReportInsightsBanner";
|
||||
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
|
||||
|
||||
export default function OperationalEfficiencyReport({ events, staff, userRole = 'admin' }) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Banner */}
|
||||
<ReportInsightsBanner userRole={userRole} reportType="efficiency" />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency</h2>
|
||||
<p className="text-sm text-slate-500">Automation impact & process optimization</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ROI Highlight */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-800">Automation ROI This Month</p>
|
||||
<p className="text-2xl font-bold text-purple-900">$24,500 saved</p>
|
||||
<p className="text-xs text-purple-600">Based on 85% automation rate × manual processing costs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-green-100 px-3 py-1 rounded-full">
|
||||
<ArrowUp className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-semibold text-green-700">+18% vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card className={`border-l-4 ${parseFloat(automationRate) >= 80 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Automation</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
|
||||
<p className={`text-xs mt-1 ${parseFloat(automationRate) >= 80 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{parseFloat(automationRate) >= 80 ? '✓ Optimal' : `${(80 - parseFloat(automationRate)).toFixed(0)}% to target`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${avgTimeToFill <= 2 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Time to Fill</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Industry avg: 4.5h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${avgResponseTime <= 1.5 ? 'border-l-green-500' : 'border-l-amber-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Response Time</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
|
||||
<p className="text-xs text-green-600 mt-1">SLA: 2h ✓</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Completed</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">of {totalEvents} total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
|
||||
<p className="text-sm font-medium text-purple-900 mt-1">
|
||||
{parseFloat(automationRate) < 80
|
||||
? 'Enable auto-assignment for recurring orders (+15% automation)'
|
||||
: avgTimeToFill > 2
|
||||
? 'Expand preferred worker pool to reduce fill time'
|
||||
: 'System performing well — document SOP for new sites'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Efficiency Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Efficiency Metrics Over Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={efficiencyTrend}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="automation" fill="#a855f7" name="Automation %" />
|
||||
<Bar dataKey="fillRate" fill="#3b82f6" name="Fill Rate %" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event Status Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{statusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Performance Indicators</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Manual Work Reduction</p>
|
||||
<p className="text-2xl font-bold text-purple-700">85%</p>
|
||||
</div>
|
||||
<Badge className="bg-purple-600">Excellent</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">First-Time Fill Rate</p>
|
||||
<p className="text-2xl font-bold text-blue-700">92%</p>
|
||||
</div>
|
||||
<Badge className="bg-blue-600">Good</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Staff Utilization</p>
|
||||
<p className="text-2xl font-bold text-green-700">88%</p>
|
||||
</div>
|
||||
<Badge className="bg-green-600">Optimal</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">Conflict Detection</p>
|
||||
<p className="text-2xl font-bold text-amber-700">97%</p>
|
||||
</div>
|
||||
<Badge className="bg-amber-600">High</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Download, FileText, Table, FileSpreadsheet,
|
||||
Printer, Share2, Link, CheckCircle, Loader2
|
||||
} from "lucide-react";
|
||||
import ReportPDFPreview from "./ReportPDFPreview";
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ id: 'pdf', label: 'PDF Document', icon: FileText, description: 'Best for sharing and printing', color: 'red' },
|
||||
{ id: 'excel', label: 'Excel Spreadsheet', icon: FileSpreadsheet, description: 'Editable with formulas', color: 'green' },
|
||||
{ id: 'csv', label: 'CSV File', icon: Table, description: 'Universal data format', color: 'blue' },
|
||||
{ id: 'json', label: 'JSON (API)', icon: Link, description: 'For system integration', color: 'purple' },
|
||||
];
|
||||
|
||||
export default function ReportExporter({
|
||||
open,
|
||||
onClose,
|
||||
reportName,
|
||||
reportData,
|
||||
onExport
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [selectedFormat, setSelectedFormat] = useState('pdf');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportOptions, setExportOptions] = useState({
|
||||
includeCharts: true,
|
||||
includeSummary: true,
|
||||
includeDetails: true,
|
||||
includeFooter: true,
|
||||
});
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Simulate export process
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Generate filename
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `${reportName?.replace(/\s+/g, '-').toLowerCase() || 'report'}-${timestamp}`;
|
||||
|
||||
// Handle different export formats
|
||||
let blob;
|
||||
let extension;
|
||||
|
||||
switch (selectedFormat) {
|
||||
case 'json':
|
||||
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
|
||||
extension = 'json';
|
||||
break;
|
||||
case 'csv':
|
||||
// Simple CSV conversion
|
||||
const csvContent = convertToCSV(reportData);
|
||||
blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
extension = 'csv';
|
||||
break;
|
||||
case 'excel':
|
||||
case 'pdf':
|
||||
default:
|
||||
// For demo, just export as JSON
|
||||
blob = new Blob([JSON.stringify(reportData || {}, null, 2)], { type: 'application/json' });
|
||||
extension = 'json';
|
||||
toast({
|
||||
title: "Note",
|
||||
description: `${selectedFormat.toUpperCase()} export would be generated server-side. Downloaded as JSON for demo.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${filename}.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "✅ Export Complete",
|
||||
description: `${reportName} has been downloaded`,
|
||||
});
|
||||
|
||||
onExport?.({ format: selectedFormat, filename });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Export Failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const convertToCSV = (data) => {
|
||||
if (!data || typeof data !== 'object') return '';
|
||||
|
||||
// Handle array of objects
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
const headers = Object.keys(data[0]);
|
||||
const rows = data.map(row =>
|
||||
headers.map(h => JSON.stringify(row[h] ?? '')).join(',')
|
||||
);
|
||||
return [headers.join(','), ...rows].join('\n');
|
||||
}
|
||||
|
||||
// Handle single object
|
||||
return Object.entries(data)
|
||||
.map(([key, value]) => `${key},${JSON.stringify(value)}`)
|
||||
.join('\n');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
const printContent = document.getElementById('report-preview-content');
|
||||
if (printContent) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>${reportName || 'Report'}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; }
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${printContent.innerHTML}</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
} else {
|
||||
window.print();
|
||||
}
|
||||
toast({ title: "Print dialog opened" });
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const shareUrl = window.location.href;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: reportName,
|
||||
text: `Check out this ${reportName} report`,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// User cancelled or error
|
||||
}
|
||||
} else {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast({ title: "Link Copied", description: "Report link copied to clipboard" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-[#0A39DF]" />
|
||||
Export Report
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={selectedFormat} onValueChange={setSelectedFormat} className="mt-4">
|
||||
<TabsList className="grid grid-cols-4 w-full">
|
||||
{EXPORT_FORMATS.map(f => {
|
||||
const Icon = f.icon;
|
||||
return (
|
||||
<TabsTrigger key={f.id} value={f.id} className="text-xs">
|
||||
<Icon className="w-3 h-3 mr-1" />
|
||||
{f.label.split(' ')[0]}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* PDF Preview */}
|
||||
<TabsContent value="pdf" className="mt-4">
|
||||
<div id="report-preview-content" className="max-h-[400px] overflow-y-auto border rounded-lg p-2 bg-slate-100">
|
||||
<ReportPDFPreview
|
||||
reportName={reportName}
|
||||
reportData={reportData}
|
||||
options={exportOptions}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Excel Preview */}
|
||||
<TabsContent value="excel" className="mt-4">
|
||||
<div className="border rounded-lg p-4 bg-green-50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FileSpreadsheet className="w-5 h-5 text-green-600" />
|
||||
<span className="font-medium text-green-800">Excel Spreadsheet</span>
|
||||
</div>
|
||||
<div className="bg-white border rounded text-xs">
|
||||
<div className="grid grid-cols-4 gap-px bg-slate-200">
|
||||
<div className="bg-green-100 p-2 font-semibold">Category</div>
|
||||
<div className="bg-green-100 p-2 font-semibold">Count</div>
|
||||
<div className="bg-green-100 p-2 font-semibold">Amount</div>
|
||||
<div className="bg-green-100 p-2 font-semibold">%</div>
|
||||
{[
|
||||
['Kitchen Staff', '45', '$42,500', '34%'],
|
||||
['Event Servers', '38', '$35,200', '28%'],
|
||||
['Bartenders', '28', '$28,800', '23%'],
|
||||
].flat().map((cell, i) => (
|
||||
<div key={i} className="bg-white p-2">{cell}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">Includes formulas, pivot tables, and multiple sheets</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* CSV Preview */}
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<div className="border rounded-lg p-4 bg-blue-50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Table className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium text-blue-800">CSV Data</span>
|
||||
</div>
|
||||
<pre className="bg-slate-900 text-green-400 p-3 rounded text-[10px] overflow-x-auto">
|
||||
{`category,count,amount,percentage
|
||||
"Kitchen Staff",45,42500,34
|
||||
"Event Servers",38,35200,28
|
||||
"Bartenders",28,28800,23
|
||||
"Support Staff",22,18500,15`}
|
||||
</pre>
|
||||
<p className="text-xs text-blue-700 mt-2">Universal format for any spreadsheet or database</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Preview */}
|
||||
<TabsContent value="json" className="mt-4">
|
||||
<div className="border rounded-lg p-4 bg-purple-50">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Link className="w-5 h-5 text-purple-600" />
|
||||
<span className="font-medium text-purple-800">JSON (API)</span>
|
||||
</div>
|
||||
<pre className="bg-slate-900 text-amber-400 p-3 rounded text-[10px] overflow-x-auto max-h-32">
|
||||
{`{
|
||||
"report": "${reportName}",
|
||||
"generated": "${new Date().toISOString()}",
|
||||
"data": {
|
||||
"totalSpend": 125000,
|
||||
"fillRate": 94.2,
|
||||
"categories": [...]
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
<p className="text-xs text-purple-700 mt-2">For API push and system integration</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Export Options */}
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t flex-wrap">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{[
|
||||
{ key: 'includeSummary', label: 'Summary' },
|
||||
{ key: 'includeCharts', label: 'Charts' },
|
||||
{ key: 'includeDetails', label: 'Details' },
|
||||
].map(opt => (
|
||||
<label key={opt.key} className="flex items-center gap-1 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={exportOptions[opt.key]}
|
||||
onCheckedChange={(c) => setExportOptions({ ...exportOptions, [opt.key]: c })}
|
||||
/>
|
||||
<span className="text-xs text-slate-600">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-3 h-3 mr-1" />
|
||||
Print
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleShare}>
|
||||
<Share2 className="w-3 h-3 mr-1" />
|
||||
Share
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
className="bg-[#0A39DF]"
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export {selectedFormat.toUpperCase()}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Clock, Brain, TrendingUp, Zap, Lightbulb, Target, AlertTriangle,
|
||||
ArrowRight, CheckCircle, DollarSign, Users, Shield, Calendar
|
||||
} from "lucide-react";
|
||||
|
||||
const ROLE_CONFIGS = {
|
||||
procurement: {
|
||||
color: 'from-blue-600 to-indigo-700',
|
||||
title: 'Procurement Intelligence',
|
||||
question: 'What should I negotiate this week?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Analysis Time', value: '12 hrs saved', trend: '+15%' },
|
||||
{ icon: Brain, label: 'Decision Speed', value: '3x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Forecast Accuracy', value: '94%', trend: '+2.1%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Renegotiate 3 vendor contracts expiring in 30 days', priority: 'high', impact: '$12,400 potential savings' },
|
||||
{ label: 'Consolidate 5 underperforming vendors into Tier 1', priority: 'medium', impact: '18% rate reduction' },
|
||||
],
|
||||
prediction: 'Next quarter spend projected at $425K — 8% below budget if consolidation executed',
|
||||
},
|
||||
operator: {
|
||||
color: 'from-emerald-600 to-teal-700',
|
||||
title: 'Operations Command Center',
|
||||
question: 'Where do I need to reallocate resources?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Report Time', value: '8 hrs saved', trend: '+22%' },
|
||||
{ icon: Brain, label: 'Reallocation Speed', value: '5x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Demand Forecast', value: '91%', trend: '+3.5%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Sector B understaffed by 12% — shift 8 workers from Sector A', priority: 'high', impact: 'Fill rate +15%' },
|
||||
{ label: 'OT exposure at 22% in kitchen roles — redistribute shifts', priority: 'medium', impact: '$8,200 OT savings' },
|
||||
],
|
||||
prediction: 'Peak demand expected Dec 15-22 — pre-approve 45 additional workers now',
|
||||
},
|
||||
sector: {
|
||||
color: 'from-purple-600 to-violet-700',
|
||||
title: 'Sector Performance Hub',
|
||||
question: 'Which sites need immediate attention?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Site Analysis', value: '6 hrs saved', trend: '+18%' },
|
||||
{ icon: Brain, label: 'Shift Decisions', value: '4x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Pattern Accuracy', value: '89%', trend: '+4.2%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Site #3 no-show rate spiked 8% — review worker pool', priority: 'high', impact: 'Reliability +12%' },
|
||||
{ label: '3 certifications expiring this week — schedule renewals', priority: 'high', impact: 'Compliance 100%' },
|
||||
],
|
||||
prediction: 'Tuesday/Wednesday historically 15% understaffed — auto-schedule buffer',
|
||||
},
|
||||
client: {
|
||||
color: 'from-green-600 to-emerald-700',
|
||||
title: 'Your Staffing Intelligence',
|
||||
question: 'How can I reduce costs without sacrificing quality?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Order Tracking', value: '4 hrs saved', trend: '+25%' },
|
||||
{ icon: Brain, label: 'Vendor Choice', value: '2x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Cost Accuracy', value: '96%', trend: '+1.8%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: 'Switch 2 orders to Preferred Vendor — same quality, lower rate', priority: 'high', impact: '$2,100 savings' },
|
||||
{ label: 'Lock in recurring staff for your top 3 positions', priority: 'medium', impact: '15% rate lock' },
|
||||
],
|
||||
prediction: 'Your Q1 staffing cost projected at $48K — $6K below last year',
|
||||
},
|
||||
vendor: {
|
||||
color: 'from-amber-600 to-orange-700',
|
||||
title: 'Vendor Growth Dashboard',
|
||||
question: 'How do I maximize revenue and retention?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Scheduling Time', value: '10 hrs saved', trend: '+30%' },
|
||||
{ icon: Brain, label: 'Assignment Match', value: '6x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Revenue Forecast', value: '92%', trend: '+2.5%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: '12 workers idle this week — propose to 3 pending orders', priority: 'high', impact: '$4,800 revenue' },
|
||||
{ label: '2 clients at churn risk — schedule check-in calls', priority: 'high', impact: '$18K annual value' },
|
||||
],
|
||||
prediction: 'December revenue projected at $125K — 22% above November',
|
||||
},
|
||||
admin: {
|
||||
color: 'from-slate-700 to-slate-900',
|
||||
title: 'Platform Command Center',
|
||||
question: 'What needs my attention across the entire system?',
|
||||
metrics: [
|
||||
{ icon: Clock, label: 'Oversight Time', value: '15 hrs saved', trend: '+35%' },
|
||||
{ icon: Brain, label: 'System Decisions', value: '8x faster', trend: 'vs manual' },
|
||||
{ icon: TrendingUp, label: 'Platform Accuracy', value: '97%', trend: '+1.2%' },
|
||||
],
|
||||
actions: [
|
||||
{ label: '3 vendors pending compliance review — approve/reject today', priority: 'high', impact: 'Onboard $45K revenue' },
|
||||
{ label: 'System automation at 85% — enable 2 more workflows', priority: 'medium', impact: '+$12K/month savings' },
|
||||
],
|
||||
prediction: 'Platform GMV projected at $2.4M this quarter — 18% growth',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ReportInsightsBanner({ userRole = 'admin', reportType, data = {} }) {
|
||||
const config = ROLE_CONFIGS[userRole] || ROLE_CONFIGS.admin;
|
||||
|
||||
return (
|
||||
<div className={`bg-gradient-to-r ${config.color} rounded-xl text-white overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5" />
|
||||
<span className="font-bold">{config.title}</span>
|
||||
<Badge className="bg-white/20 text-white border-0 text-[10px]">AI-Powered</Badge>
|
||||
</div>
|
||||
<span className="text-sm opacity-80 italic">"{config.question}"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-3 divide-x divide-white/10">
|
||||
{config.metrics.map((metric, idx) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<div key={idx} className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Icon className="w-4 h-4 opacity-80" />
|
||||
<span className="text-xs opacity-80">{metric.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{metric.value}</p>
|
||||
<p className="text-[11px] text-green-300">{metric.trend}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-black/20">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2 opacity-80">
|
||||
⚡ Recommended Actions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{config.actions.map((action, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between bg-white/10 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{action.priority === 'high' ? (
|
||||
<AlertTriangle className="w-4 h-4 text-amber-300" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 text-green-300" />
|
||||
)}
|
||||
<span className="text-sm">{action.label}</span>
|
||||
</div>
|
||||
<Badge className="bg-white/20 text-white border-0 text-[10px]">
|
||||
{action.impact}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prediction */}
|
||||
<div className="p-3 bg-white/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Prediction:</span>
|
||||
<span className="text-sm opacity-90">{config.prediction}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 text-xs bg-white text-slate-900 hover:bg-white/90"
|
||||
onClick={() => window.location.href = '/SavingsEngine'}
|
||||
>
|
||||
Take Action <ArrowRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { format } from "date-fns";
|
||||
import { FileText, Building2, Calendar, User, TrendingUp, DollarSign, Users, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function ReportPDFPreview({ reportName, reportData, options = {} }) {
|
||||
const now = new Date();
|
||||
const {
|
||||
includeSummary = true,
|
||||
includeCharts = true,
|
||||
includeDetails = true,
|
||||
includeFooter = true,
|
||||
} = options;
|
||||
|
||||
// Sample metrics for preview
|
||||
const metrics = {
|
||||
totalSpend: reportData?.invoices?.reduce((s, i) => s + (i.amount || 0), 0) || 125000,
|
||||
eventCount: reportData?.events?.length || 48,
|
||||
staffCount: reportData?.staff?.length || 156,
|
||||
vendorCount: reportData?.vendors?.length || 12,
|
||||
fillRate: 94.2,
|
||||
avgRate: 42.50,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border-2 border-slate-200 rounded-lg shadow-lg max-w-2xl mx-auto" style={{ aspectRatio: '8.5/11' }}>
|
||||
{/* PDF Page Container */}
|
||||
<div className="p-6 h-full flex flex-col text-sm">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#1C323E] rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">K</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-[#1C323E]">KROW</h1>
|
||||
<p className="text-[10px] text-slate-500">Workforce Control Tower</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-slate-500">
|
||||
<p>Generated: {format(now, 'MMM d, yyyy h:mm a')}</p>
|
||||
<p>Report ID: RPT-{Math.random().toString(36).substr(2, 8).toUpperCase()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report Title */}
|
||||
<div className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] text-white p-4 rounded-lg mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileText className="w-5 h-5" />
|
||||
<h2 className="text-base font-bold">{reportName || 'Workforce Report'}</h2>
|
||||
</div>
|
||||
<p className="text-white/80 text-xs">
|
||||
Period: {format(new Date(now.getFullYear(), now.getMonth(), 1), 'MMM d')} - {format(now, 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Executive Summary */}
|
||||
{includeSummary && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" /> Executive Summary
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-blue-50 p-2 rounded border border-blue-100">
|
||||
<p className="text-[10px] text-blue-600 font-medium">Total Spend</p>
|
||||
<p className="text-sm font-bold text-blue-700">${metrics.totalSpend.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 p-2 rounded border border-emerald-100">
|
||||
<p className="text-[10px] text-emerald-600 font-medium">Fill Rate</p>
|
||||
<p className="text-sm font-bold text-emerald-700">{metrics.fillRate}%</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-2 rounded border border-purple-100">
|
||||
<p className="text-[10px] text-purple-600 font-medium">Avg Rate</p>
|
||||
<p className="text-sm font-bold text-purple-700">${metrics.avgRate}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart Placeholder */}
|
||||
{includeCharts && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3" /> Spend Breakdown
|
||||
</h3>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded p-3 h-24 flex items-center justify-center">
|
||||
<div className="flex items-end gap-1 h-16">
|
||||
{[65, 45, 80, 55, 70, 40, 85].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-6 bg-gradient-to-t from-[#0A39DF] to-blue-400 rounded-t"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-4 text-[10px] text-slate-500">
|
||||
<p>Weekly trend</p>
|
||||
<p className="text-emerald-600">↑ 12% vs prior</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
{includeDetails && (
|
||||
<div className="mb-4 flex-1">
|
||||
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2 flex items-center gap-1">
|
||||
<Users className="w-3 h-3" /> Detailed Breakdown
|
||||
</h3>
|
||||
<div className="border border-slate-200 rounded overflow-hidden">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="text-left p-1.5 font-semibold">Category</th>
|
||||
<th className="text-right p-1.5 font-semibold">Count</th>
|
||||
<th className="text-right p-1.5 font-semibold">Amount</th>
|
||||
<th className="text-right p-1.5 font-semibold">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ cat: 'Kitchen Staff', count: 45, amount: 42500, pct: 34 },
|
||||
{ cat: 'Event Servers', count: 38, amount: 35200, pct: 28 },
|
||||
{ cat: 'Bartenders', count: 28, amount: 28800, pct: 23 },
|
||||
{ cat: 'Support Staff', count: 22, amount: 18500, pct: 15 },
|
||||
].map((row, i) => (
|
||||
<tr key={i} className={i % 2 ? 'bg-slate-50' : ''}>
|
||||
<td className="p-1.5">{row.cat}</td>
|
||||
<td className="p-1.5 text-right">{row.count}</td>
|
||||
<td className="p-1.5 text-right">${row.amount.toLocaleString()}</td>
|
||||
<td className="p-1.5 text-right">{row.pct}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-slate-100 font-semibold">
|
||||
<tr>
|
||||
<td className="p-1.5">Total</td>
|
||||
<td className="p-1.5 text-right">133</td>
|
||||
<td className="p-1.5 text-right">$125,000</td>
|
||||
<td className="p-1.5 text-right">100%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Insights */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-bold text-slate-700 uppercase tracking-wider mb-2">Key Insights</h3>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
'Fill rate improved 3.2% month-over-month',
|
||||
'Kitchen staff utilization at 94% capacity',
|
||||
'Top vendor: Premier Staffing (42% of orders)',
|
||||
].map((insight, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[10px] text-slate-600">
|
||||
<CheckCircle className="w-3 h-3 text-emerald-500 flex-shrink-0" />
|
||||
{insight}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{includeFooter && (
|
||||
<div className="mt-auto pt-3 border-t border-slate-200">
|
||||
<div className="flex items-center justify-between text-[9px] text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-3 h-3" />
|
||||
<span>KROW Workforce Control Tower</span>
|
||||
</div>
|
||||
<span>Confidential - Internal Use Only</span>
|
||||
<span>Page 1 of 1</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FileText, DollarSign, Users, TrendingUp, Shield, Clock,
|
||||
Building2, Package, AlertTriangle, Star, Search, Filter,
|
||||
Download, Eye, Zap, BarChart3, PieChart, MapPin, Calendar
|
||||
} from "lucide-react";
|
||||
|
||||
const REPORT_TEMPLATES = [
|
||||
// Procurement Reports
|
||||
{ id: 'vendor-spend-analysis', name: 'Vendor Spend Analysis', category: 'Procurement', icon: DollarSign, color: 'blue', description: 'Detailed breakdown of spend by vendor, tier, and region', roles: ['procurement', 'admin'] },
|
||||
{ id: 'vendor-performance-scorecard', name: 'Vendor Performance Scorecard', category: 'Procurement', icon: Star, color: 'amber', description: 'Fill rates, reliability scores, and SLA compliance by vendor', roles: ['procurement', 'admin'] },
|
||||
{ id: 'rate-compliance', name: 'Rate Compliance Report', category: 'Procurement', icon: Shield, color: 'green', description: 'Contracted vs spot rate usage and savings opportunities', roles: ['procurement', 'admin'] },
|
||||
{ id: 'vendor-consolidation', name: 'Vendor Consolidation Analysis', category: 'Procurement', icon: Package, color: 'purple', description: 'Opportunities to consolidate vendors for better rates', roles: ['procurement', 'admin'] },
|
||||
|
||||
// Operator Reports
|
||||
{ id: 'enterprise-labor-summary', name: 'Enterprise Labor Summary', category: 'Operator', icon: Building2, color: 'indigo', description: 'Cross-sector labor costs, utilization, and trends', roles: ['operator', 'admin'] },
|
||||
{ id: 'sector-comparison', name: 'Sector Comparison Report', category: 'Operator', icon: BarChart3, color: 'blue', description: 'Performance benchmarks across all sectors', roles: ['operator', 'admin'] },
|
||||
{ id: 'overtime-exposure', name: 'Overtime Exposure Report', category: 'Operator', icon: Clock, color: 'red', description: 'OT hours by sector, site, and worker with cost impact', roles: ['operator', 'admin', 'sector'] },
|
||||
{ id: 'fill-rate-analysis', name: 'Fill Rate Analysis', category: 'Operator', icon: TrendingUp, color: 'emerald', description: 'Order fulfillment rates and gap analysis', roles: ['operator', 'admin', 'sector'] },
|
||||
|
||||
// Sector Reports
|
||||
{ id: 'site-labor-allocation', name: 'Site Labor Allocation', category: 'Sector', icon: MapPin, color: 'purple', description: 'Cost allocation by site, department, and cost center', roles: ['sector', 'operator', 'admin'] },
|
||||
{ id: 'attendance-patterns', name: 'Attendance Patterns Report', category: 'Sector', icon: Calendar, color: 'blue', description: 'No-shows, late arrivals, and attendance trends', roles: ['sector', 'operator', 'admin'] },
|
||||
{ id: 'worker-reliability', name: 'Worker Reliability Index', category: 'Sector', icon: Users, color: 'green', description: 'Individual worker performance and reliability scores', roles: ['sector', 'vendor', 'admin'] },
|
||||
{ id: 'compliance-risk', name: 'Compliance Risk Report', category: 'Sector', icon: AlertTriangle, color: 'amber', description: 'Certification expirations, background check status', roles: ['sector', 'admin'] },
|
||||
|
||||
// Vendor Reports
|
||||
{ id: 'client-revenue', name: 'Client Revenue Report', category: 'Vendor', icon: DollarSign, color: 'emerald', description: 'Revenue by client, event type, and time period', roles: ['vendor', 'admin'] },
|
||||
{ id: 'workforce-utilization', name: 'Workforce Utilization', category: 'Vendor', icon: Users, color: 'blue', description: 'Staff hours, availability, and utilization rates', roles: ['vendor', 'admin'] },
|
||||
{ id: 'margin-analysis', name: 'Margin Analysis Report', category: 'Vendor', icon: TrendingUp, color: 'purple', description: 'Profit margins by client, role, and event type', roles: ['vendor', 'admin'] },
|
||||
{ id: 'staff-performance', name: 'Staff Performance Report', category: 'Vendor', icon: Star, color: 'amber', description: 'Ratings, feedback, and performance trends', roles: ['vendor', 'admin'] },
|
||||
|
||||
// Finance & Compliance
|
||||
{ id: 'invoice-aging', name: 'Invoice Aging Report', category: 'Finance', icon: FileText, color: 'slate', description: 'Outstanding invoices by age and status', roles: ['admin', 'procurement', 'vendor'] },
|
||||
{ id: 'payroll-summary', name: 'Payroll Summary Report', category: 'Finance', icon: DollarSign, color: 'green', description: 'Total labor costs, taxes, and deductions', roles: ['admin', 'vendor'] },
|
||||
{ id: 'audit-trail', name: 'Audit Trail Report', category: 'Compliance', icon: Shield, color: 'indigo', description: 'All system changes and user activities', roles: ['admin'] },
|
||||
{ id: 'certification-status', name: 'Certification Status Report', category: 'Compliance', icon: AlertTriangle, color: 'red', description: 'Expiring and missing certifications', roles: ['admin', 'sector', 'vendor'] },
|
||||
|
||||
// Client Reports
|
||||
{ id: 'event-cost-summary', name: 'Event Cost Summary', category: 'Client', icon: Calendar, color: 'blue', description: 'Detailed costs for all events with breakdown', roles: ['client', 'admin'] },
|
||||
{ id: 'savings-report', name: 'Savings Achieved Report', category: 'Client', icon: Zap, color: 'emerald', description: 'Cost savings from preferred vendor usage', roles: ['client', 'admin'] },
|
||||
{ id: 'staff-feedback', name: 'Staff Feedback Report', category: 'Client', icon: Star, color: 'amber', description: 'Ratings and feedback on assigned staff', roles: ['client', 'admin'] },
|
||||
];
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'Procurement': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'Operator': 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
'Sector': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'Vendor': 'bg-amber-100 text-amber-700 border-amber-200',
|
||||
'Finance': 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
'Compliance': 'bg-red-100 text-red-700 border-red-200',
|
||||
'Client': 'bg-green-100 text-green-700 border-green-200',
|
||||
};
|
||||
|
||||
const ICON_COLORS = {
|
||||
blue: 'bg-blue-500',
|
||||
amber: 'bg-amber-500',
|
||||
green: 'bg-green-500',
|
||||
purple: 'bg-purple-500',
|
||||
indigo: 'bg-indigo-500',
|
||||
red: 'bg-red-500',
|
||||
emerald: 'bg-emerald-500',
|
||||
slate: 'bg-slate-500',
|
||||
};
|
||||
|
||||
export default function ReportTemplateLibrary({ userRole, onSelectTemplate, onPreview }) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
|
||||
const filteredTemplates = REPORT_TEMPLATES.filter(template => {
|
||||
const matchesRole = template.roles.includes(userRole) || userRole === 'admin';
|
||||
const matchesSearch = template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = categoryFilter === "all" || template.category === categoryFilter;
|
||||
return matchesRole && matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const categories = [...new Set(REPORT_TEMPLATES.map(t => t.category))];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Search & Filters - Clean Row */}
|
||||
<div className="flex items-center gap-3 flex-wrap bg-white p-3 rounded-xl border">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Search reports..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-50 border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={categoryFilter === "all" ? "default" : "ghost"}
|
||||
onClick={() => setCategoryFilter("all")}
|
||||
className={`h-8 px-3 ${categoryFilter === "all" ? "bg-[#0A39DF]" : "text-slate-600"}`}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{categories.map(cat => (
|
||||
<Button
|
||||
key={cat}
|
||||
size="sm"
|
||||
variant={categoryFilter === cat ? "default" : "ghost"}
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`h-8 px-3 ${categoryFilter === cat ? "bg-[#0A39DF]" : "text-slate-600"}`}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-auto text-slate-500 font-normal">
|
||||
{filteredTemplates.length} reports
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Template Grid - Improved Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filteredTemplates.map(template => {
|
||||
const Icon = template.icon;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="bg-white rounded-xl border border-slate-200 hover:border-blue-400 hover:shadow-lg transition-all cursor-pointer group overflow-hidden"
|
||||
onClick={() => onSelectTemplate?.(template)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className={`w-10 h-10 ${ICON_COLORS[template.color]} rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm text-slate-900 group-hover:text-blue-700 leading-tight">
|
||||
{template.name}
|
||||
</h4>
|
||||
<Badge className={`${CATEGORY_COLORS[template.category]} text-[10px] px-1.5 py-0 mt-1`}>
|
||||
{template.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 line-clamp-2 mb-3 min-h-[32px]">{template.description}</p>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-9 bg-[#0A39DF] hover:bg-[#0831b8] font-medium"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectTemplate?.(template); }}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
Generate Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredTemplates.length === 0 && (
|
||||
<div className="text-center py-16 bg-white rounded-xl border">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300" />
|
||||
<p className="text-slate-500 font-medium">No reports match your search</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Try adjusting your filters</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Clock, Calendar, Mail, Plus, Trash2, Edit2, Play, Pause,
|
||||
Coffee, Sun, Moon, FileText, Download, Bell, Send
|
||||
} from "lucide-react";
|
||||
|
||||
const SCHEDULE_OPTIONS = [
|
||||
{ value: 'daily', label: 'Daily', icon: Sun, description: 'Every morning at 7 AM' },
|
||||
{ value: 'weekly', label: 'Weekly', icon: Calendar, description: 'Every Monday morning' },
|
||||
{ value: 'biweekly', label: 'Bi-Weekly', icon: Calendar, description: 'Every other Monday' },
|
||||
{ value: 'monthly', label: 'Monthly', icon: Calendar, description: 'First of each month' },
|
||||
{ value: 'quarterly', label: 'Quarterly', icon: Calendar, description: 'Start of each quarter' },
|
||||
];
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ value: '06:00', label: '6:00 AM - Early Bird' },
|
||||
{ value: '07:00', label: '7:00 AM - First Coffee ☕' },
|
||||
{ value: '08:00', label: '8:00 AM - Start of Day' },
|
||||
{ value: '09:00', label: '9:00 AM - Morning' },
|
||||
{ value: '12:00', label: '12:00 PM - Midday' },
|
||||
{ value: '17:00', label: '5:00 PM - End of Day' },
|
||||
];
|
||||
|
||||
export default function ScheduledReports({ userRole, scheduledReports = [], onUpdate }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingReport, setEditingReport] = useState(null);
|
||||
const [newSchedule, setNewSchedule] = useState({
|
||||
report_name: '',
|
||||
report_type: '',
|
||||
frequency: 'weekly',
|
||||
time: '07:00',
|
||||
recipients: '',
|
||||
format: 'pdf',
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const createScheduleMutation = useMutation({
|
||||
mutationFn: async (scheduleData) => {
|
||||
// In real app, this would create a scheduled job
|
||||
// For now, we'll save it to user preferences
|
||||
await base44.auth.updateMe({
|
||||
scheduled_reports: [...(scheduledReports || []), {
|
||||
...scheduleData,
|
||||
id: Date.now().toString(),
|
||||
created_at: new Date().toISOString()
|
||||
}]
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "✅ Schedule Created", description: "Your report will be delivered automatically" });
|
||||
setShowCreateModal(false);
|
||||
setNewSchedule({ report_name: '', report_type: '', frequency: 'weekly', time: '07:00', recipients: '', format: 'pdf', is_active: true });
|
||||
onUpdate?.();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleScheduleMutation = useMutation({
|
||||
mutationFn: async ({ id, is_active }) => {
|
||||
const updated = scheduledReports.map(r =>
|
||||
r.id === id ? { ...r, is_active } : r
|
||||
);
|
||||
await base44.auth.updateMe({ scheduled_reports: updated });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Schedule Updated" });
|
||||
onUpdate?.();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteScheduleMutation = useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const updated = scheduledReports.filter(r => r.id !== id);
|
||||
await base44.auth.updateMe({ scheduled_reports: updated });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Schedule Deleted" });
|
||||
onUpdate?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newSchedule.report_name || !newSchedule.recipients) {
|
||||
toast({ title: "Missing Fields", description: "Please fill in all required fields", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
createScheduleMutation.mutate(newSchedule);
|
||||
};
|
||||
|
||||
const getFrequencyLabel = (freq) => {
|
||||
const option = SCHEDULE_OPTIONS.find(o => o.value === freq);
|
||||
return option?.label || freq;
|
||||
};
|
||||
|
||||
const getNextDelivery = (schedule) => {
|
||||
const now = new Date();
|
||||
const [hours, minutes] = schedule.time.split(':').map(Number);
|
||||
const next = new Date(now);
|
||||
next.setHours(hours, minutes, 0, 0);
|
||||
|
||||
if (next <= now) {
|
||||
switch (schedule.frequency) {
|
||||
case 'daily':
|
||||
next.setDate(next.getDate() + 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
next.setDate(next.getDate() + (7 - now.getDay() + 1) % 7 || 7);
|
||||
break;
|
||||
default:
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return next.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Compact Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Coffee className="w-5 h-5 text-[#0A39DF]" />
|
||||
<span className="font-semibold text-slate-900">Scheduled Reports</span>
|
||||
<span className="text-sm text-slate-500">— delivered like your morning coffee</span>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-[#0A39DF]">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Compact Scheduled Reports List */}
|
||||
{scheduledReports?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{scheduledReports.map(schedule => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`flex items-center gap-4 p-3 rounded-lg border ${schedule.is_active ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100 opacity-60'}`}
|
||||
>
|
||||
<Switch
|
||||
checked={schedule.is_active}
|
||||
onCheckedChange={(checked) => toggleScheduleMutation.mutate({ id: schedule.id, is_active: checked })}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900 truncate">{schedule.report_name}</span>
|
||||
<Badge className="bg-purple-100 text-purple-700 text-[10px]">{getFrequencyLabel(schedule.frequency)}</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">{schedule.format?.toUpperCase()}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 mt-1">
|
||||
<span><Clock className="w-3 h-3 inline mr-1" />{schedule.time}</span>
|
||||
<span className="truncate"><Mail className="w-3 h-3 inline mr-1" />{schedule.recipients}</span>
|
||||
{schedule.is_active && <span className="text-emerald-600">Next: {getNextDelivery(schedule)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => { setEditingReport(schedule); setShowCreateModal(true); }}>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-blue-600">
|
||||
<Send className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-red-600" onClick={() => deleteScheduleMutation.mutate(schedule.id)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Clock className="w-8 h-8 mx-auto mb-2 text-slate-300" />
|
||||
<p className="text-sm">No scheduled reports yet</p>
|
||||
<Button onClick={() => setShowCreateModal(true)} size="sm" variant="link" className="text-[#0A39DF]">
|
||||
Create your first schedule →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-[#0A39DF]" />
|
||||
{editingReport ? 'Edit Schedule' : 'Create Report Schedule'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Report Name *</label>
|
||||
<Input
|
||||
value={newSchedule.report_name}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, report_name: e.target.value })}
|
||||
placeholder="e.g., Weekly Labor Summary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Frequency</label>
|
||||
<Select
|
||||
value={newSchedule.frequency}
|
||||
onValueChange={(v) => setNewSchedule({ ...newSchedule, frequency: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SCHEDULE_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<opt.icon className="w-4 h-4" />
|
||||
{opt.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Delivery Time</label>
|
||||
<Select
|
||||
value={newSchedule.time}
|
||||
onValueChange={(v) => setNewSchedule({ ...newSchedule, time: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Recipients *</label>
|
||||
<Input
|
||||
value={newSchedule.recipients}
|
||||
onChange={(e) => setNewSchedule({ ...newSchedule, recipients: e.target.value })}
|
||||
placeholder="email@company.com, another@company.com"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Separate multiple emails with commas</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Format</label>
|
||||
<Select
|
||||
value={newSchedule.format}
|
||||
onValueChange={(v) => setNewSchedule({ ...newSchedule, format: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pdf">📄 PDF Document</SelectItem>
|
||||
<SelectItem value="excel">📊 Excel Spreadsheet</SelectItem>
|
||||
<SelectItem value="csv">📋 CSV File</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<p className="text-xs text-blue-800">
|
||||
<Coffee className="w-3 h-3 inline mr-1" />
|
||||
<strong>Pro Tip:</strong> 7 AM delivery ensures your report is ready with your morning coffee
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateModal(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#0A39DF]"
|
||||
disabled={createScheduleMutation.isPending}
|
||||
>
|
||||
{createScheduleMutation.isPending ? 'Creating...' : 'Create Schedule'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
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, AlertTriangle, Award } from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ReportInsightsBanner from "./ReportInsightsBanner";
|
||||
|
||||
export default function StaffPerformanceReport({ staff, events, userRole = 'admin' }) {
|
||||
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" });
|
||||
};
|
||||
|
||||
// At-risk workers
|
||||
const atRiskWorkers = staffMetrics.filter(s => s.reliability < 70 || s.noShows > 2);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Banner */}
|
||||
<ReportInsightsBanner userRole={userRole} reportType="performance" />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Workforce Performance</h2>
|
||||
<p className="text-sm text-slate-500">Reliability, fill rates & actionable insights</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{atRiskWorkers.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<span className="text-sm font-medium text-amber-800">
|
||||
{atRiskWorkers.length} workers need attention (reliability <70% or 2+ no-shows)
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
View At-Risk Workers
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className={`border-l-4 ${avgReliability >= 85 ? 'border-l-green-500' : avgReliability >= 70 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Reliability</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
|
||||
<p className={`text-xs mt-1 ${avgReliability >= 85 ? 'text-green-600' : 'text-amber-600'}`}>
|
||||
{avgReliability >= 85 ? '✓ Excellent' : avgReliability >= 70 ? '⚠ Needs attention' : '✗ Critical'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${avgFillRate >= 90 ? 'border-l-green-500' : avgFillRate >= 75 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Avg Fill Rate</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{avgFillRate >= 90 ? 'Above target' : `${(90 - avgFillRate).toFixed(1)}% below target`}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${totalCancellations < 5 ? 'border-l-green-500' : totalCancellations < 15 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Cancellations</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
|
||||
<p className="text-xs text-red-600 mt-1">~${(totalCancellations * 150).toLocaleString()} impact</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
|
||||
<p className="text-sm font-medium text-purple-900 mt-1">
|
||||
{atRiskWorkers.length > 0
|
||||
? `Review ${atRiskWorkers.length} at-risk workers before next scheduling cycle`
|
||||
: avgFillRate < 90
|
||||
? 'Expand worker pool by 15% to improve fill rate'
|
||||
: 'Performance healthy — consider bonuses for top 10%'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Fill Rate Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fill Rate Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={fillRateRanges}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="range" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="count" fill="#0A39DF" name="Staff Count" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Performers Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Performers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Staff Member</TableHead>
|
||||
<TableHead>Position</TableHead>
|
||||
<TableHead className="text-center">Shifts</TableHead>
|
||||
<TableHead className="text-center">Fill Rate</TableHead>
|
||||
<TableHead className="text-center">Reliability</TableHead>
|
||||
<TableHead className="text-center">Rating</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{topPerformers.map((staff) => (
|
||||
<TableRow key={staff.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-700 text-xs">
|
||||
{staff.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{staff.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">{staff.position}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{staff.completedShifts}/{staff.totalShifts}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={
|
||||
staff.fillRate >= 90 ? "bg-green-500" :
|
||||
staff.fillRate >= 75 ? "bg-blue-500" : "bg-amber-500"
|
||||
}>
|
||||
{staff.fillRate}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className="bg-purple-500">{staff.reliability}%</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{staff.rating}/5</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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, Lightbulb, Clock, Brain, Target } from "lucide-react";
|
||||
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import ReportInsightsBanner from "./ReportInsightsBanner";
|
||||
|
||||
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
|
||||
|
||||
export default function StaffingCostReport({ events, invoices, userRole = 'admin' }) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* AI Insights Banner */}
|
||||
<ReportInsightsBanner userRole={userRole} reportType="costs" />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">Labor Spend Analysis</h2>
|
||||
<p className="text-sm text-slate-500">Track spending, budget compliance & cost optimization</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={dateRange} onValueChange={setDateRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleExport} size="sm" className="bg-[#0A39DF]">
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Total Spent</p>
|
||||
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">This period</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget</p>
|
||||
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
<p className="text-xs text-green-600 mt-1">${(totalBudget - totalSpent).toLocaleString()} remaining</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className={`border-l-4 ${adherence < 90 ? 'border-l-green-500' : adherence < 100 ? 'border-l-amber-500' : 'border-l-red-500'}`}>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-slate-500 uppercase tracking-wider">Budget Used</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
|
||||
<Badge className={`mt-1 ${adherence < 90 ? "bg-green-100 text-green-700" : adherence < 100 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>
|
||||
{adherence < 90 ? "✓ Under Budget" : adherence < 100 ? "⚠ On Track" : "✗ Over Budget"}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500 bg-purple-50">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<p className="text-xs text-purple-600 uppercase tracking-wider font-semibold">Quick Decision</p>
|
||||
<p className="text-sm font-medium text-purple-900 mt-1">
|
||||
{adherence < 90
|
||||
? "You have budget room — consider pre-booking Q1 staff at locked rates"
|
||||
: adherence < 100
|
||||
? "Monitor closely — reduce OT by 10% to stay on track"
|
||||
: "Action needed — cut 15% discretionary spend immediately"
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly Cost Trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Cost Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={monthlyData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="cost" stroke="#0A39DF" strokeWidth={2} name="Actual Cost" />
|
||||
<Line type="monotone" dataKey="budget" stroke="#10b981" strokeWidth={2} strokeDasharray="5 5" name="Budget" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Department Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Costs by Department</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={departmentData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{departmentData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => `$${value.toLocaleString()}`} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Department Spending</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{departmentData.slice(0, 5).map((dept, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{dept.name}</span>
|
||||
<Badge variant="outline">${dept.value.toLocaleString()}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Target, ArrowRight, CheckCircle, AlertTriangle,
|
||||
TrendingUp, Users, DollarSign, Zap, Star, Shield, Layers
|
||||
} from "lucide-react";
|
||||
import ConversionModal from "./ConversionModal";
|
||||
|
||||
export default function ContractConversionMap({ assignments, vendors, workforce, metrics, userRole }) {
|
||||
const [selectedOpportunity, setSelectedOpportunity] = useState(null);
|
||||
|
||||
// Role-specific opportunities with fallback spend values
|
||||
const baseSpend = metrics.totalSpend || 250000; // Default $250k if no data
|
||||
|
||||
const getProcurementOpportunities = () => [
|
||||
{
|
||||
id: 1,
|
||||
category: "Vendor Tier Consolidation",
|
||||
description: "Consolidate Tier 3 vendors into Tier 1 preferred network for better rates",
|
||||
currentSpend: baseSpend * 0.25,
|
||||
currentRate: metrics.avgNonContractedRate || 52,
|
||||
targetRate: metrics.avgContractedRate || 42,
|
||||
potentialSavings: (baseSpend * 0.25) * 0.19,
|
||||
savingsPercent: 19,
|
||||
priority: "high",
|
||||
count: Math.max(Math.floor(vendors.length * 0.4), 5),
|
||||
countLabel: "vendors",
|
||||
recommendation: "Consolidate to Preferred Network",
|
||||
benefits: ["19% rate reduction", "Stronger SLAs", "Simplified vendor management"],
|
||||
tierFrom: "Standard Vendors",
|
||||
tierTo: "Preferred Network",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "Multi-Vendor Spend Consolidation",
|
||||
description: "Reduce vendor fragmentation by consolidating spend with top 3 partners",
|
||||
currentSpend: baseSpend * 0.20,
|
||||
currentRate: 48,
|
||||
targetRate: 40,
|
||||
potentialSavings: (baseSpend * 0.20) * 0.17,
|
||||
savingsPercent: 17,
|
||||
priority: "high",
|
||||
count: 5,
|
||||
countLabel: "vendors to consolidate",
|
||||
recommendation: "Volume-based rate negotiation",
|
||||
benefits: ["Volume discounts", "Single invoice processing", "Better compliance"],
|
||||
tierFrom: "5+ Vendors",
|
||||
tierTo: "2 Partners",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: "Rate Card Optimization",
|
||||
description: "Renegotiate rates with underperforming vendors or replace with preferred",
|
||||
currentSpend: baseSpend * 0.15,
|
||||
currentRate: 65,
|
||||
targetRate: 55,
|
||||
potentialSavings: (baseSpend * 0.15) * 0.15,
|
||||
savingsPercent: 15,
|
||||
priority: "medium",
|
||||
count: Math.max(Math.floor(vendors.length * 0.2), 3),
|
||||
countLabel: "vendors above market",
|
||||
recommendation: "Rate renegotiation or replacement",
|
||||
benefits: ["Market-aligned rates", "Performance guarantees", "Contract leverage"],
|
||||
tierFrom: "Above Market",
|
||||
tierTo: "Competitive Rates",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: "Regional Vendor Optimization",
|
||||
description: "Add regional preferred vendors to reduce travel costs and improve response",
|
||||
currentSpend: baseSpend * 0.12,
|
||||
currentRate: 50,
|
||||
targetRate: 44,
|
||||
potentialSavings: (baseSpend * 0.12) * 0.12,
|
||||
savingsPercent: 12,
|
||||
priority: "medium",
|
||||
count: 3,
|
||||
countLabel: "regions underserved",
|
||||
recommendation: "Onboard regional vendors",
|
||||
benefits: ["Local expertise", "Reduced travel fees", "Faster fulfillment"],
|
||||
tierFrom: "National Only",
|
||||
tierTo: "Regional Network",
|
||||
},
|
||||
];
|
||||
|
||||
const getClientOpportunities = () => [
|
||||
{
|
||||
id: 1,
|
||||
category: "Staff Rate Optimization",
|
||||
description: "Request staff through preferred vendors for lower hourly rates",
|
||||
currentSpend: baseSpend * 0.25,
|
||||
currentRate: metrics.avgNonContractedRate || 52,
|
||||
targetRate: metrics.avgContractedRate || 42,
|
||||
potentialSavings: (baseSpend * 0.25) * 0.19,
|
||||
savingsPercent: 19,
|
||||
priority: "high",
|
||||
count: Math.max(Math.floor(workforce.length * 0.3), 5),
|
||||
countLabel: "staff positions",
|
||||
recommendation: "Use Preferred Vendor Network",
|
||||
benefits: ["Lower hourly rates", "Same quality staff", "Better reliability"],
|
||||
tierFrom: "Standard Rates",
|
||||
tierTo: "Preferred Rates",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "Recurring Staff Savings",
|
||||
description: "Convert recurring positions to contracted rates for consistent savings",
|
||||
currentSpend: baseSpend * 0.20,
|
||||
currentRate: 48,
|
||||
targetRate: 40,
|
||||
potentialSavings: (baseSpend * 0.20) * 0.17,
|
||||
savingsPercent: 17,
|
||||
priority: "high",
|
||||
count: Math.max(Math.floor(workforce.length * 0.25), 4),
|
||||
countLabel: "recurring positions",
|
||||
recommendation: "Lock in contracted rates",
|
||||
benefits: ["Predictable costs", "Priority staffing", "Dedicated workforce"],
|
||||
tierFrom: "Spot Rates",
|
||||
tierTo: "Contracted",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: "Skill-Matched Staffing",
|
||||
description: "Match staff skills to reduce overtime and improve productivity",
|
||||
currentSpend: baseSpend * 0.15,
|
||||
currentRate: 65,
|
||||
targetRate: 55,
|
||||
potentialSavings: (baseSpend * 0.15) * 0.15,
|
||||
savingsPercent: 15,
|
||||
priority: "medium",
|
||||
count: Math.max(Math.floor(workforce.length * 0.2), 3),
|
||||
countLabel: "positions",
|
||||
recommendation: "Request skill-verified staff",
|
||||
benefits: ["Less overtime", "Higher productivity", "Fewer replacements"],
|
||||
tierFrom: "General Staff",
|
||||
tierTo: "Skill-Matched",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: "Favorite Staff Pool",
|
||||
description: "Build a pool of preferred workers for consistent quality and rates",
|
||||
currentSpend: baseSpend * 0.12,
|
||||
currentRate: 50,
|
||||
targetRate: 44,
|
||||
potentialSavings: (baseSpend * 0.12) * 0.12,
|
||||
savingsPercent: 12,
|
||||
priority: "medium",
|
||||
count: Math.max(Math.floor(workforce.length * 0.15), 3),
|
||||
countLabel: "workers to add",
|
||||
recommendation: "Create favorite staff list",
|
||||
benefits: ["Familiar with your operations", "Faster onboarding", "Priority booking"],
|
||||
tierFrom: "Random Assignment",
|
||||
tierTo: "Preferred Workers",
|
||||
},
|
||||
];
|
||||
|
||||
const opportunities = userRole === "client" ? getClientOpportunities() : getProcurementOpportunities();
|
||||
|
||||
const priorityColors = {
|
||||
high: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-red-100 text-red-700" },
|
||||
medium: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-amber-100 text-amber-700" },
|
||||
low: { bg: "bg-white", border: "border-slate-200", text: "text-slate-900", badge: "bg-slate-100 text-slate-700" },
|
||||
};
|
||||
|
||||
const totalPotentialSavings = opportunities.reduce((sum, o) => sum + o.potentialSavings, 0);
|
||||
const totalCount = opportunities.reduce((sum, o) => sum + o.count, 0);
|
||||
const countLabel = userRole === "client" ? "staff positions" : "vendors";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Banner */}
|
||||
<Card className="bg-[#1C323E] text-white border-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Layers className="w-6 h-6" />
|
||||
<h3 className="text-xl font-bold">Rate & Vendor Optimization</h3>
|
||||
</div>
|
||||
<p className="text-slate-300">
|
||||
{userRole === "client"
|
||||
? `Optimize ${totalCount} staff positions for better rates`
|
||||
: `Consolidate ${totalCount} ${countLabel} into preferred network`
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{userRole === "client"
|
||||
? "Lower rates • Same quality staff • Better reliability"
|
||||
: "Lower rates • Stronger partnerships • Simplified management"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right bg-white/10 rounded-xl p-4">
|
||||
<p className="text-sm text-slate-300">Total Optimization Potential</p>
|
||||
<p className="text-4xl font-bold">${totalPotentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
<p className="text-sm text-slate-300">annual savings</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* How Rate Optimization Works */}
|
||||
<Card className="border border-slate-200">
|
||||
<CardContent className="p-5">
|
||||
<h4 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-[#0A39DF]" />
|
||||
How Rate Optimization Works
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-lg font-bold">1</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">Identify Workers</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Workers with higher-tier vendors</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-lg font-bold">2</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">Compare Rates</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Match to preferred vendor rates</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-[#0A39DF] text-white rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-lg font-bold">3</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">Reassign</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Move to preferred vendors</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="w-10 h-10 bg-emerald-600 text-white rounded-full flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-lg font-bold">4</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900">Save</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Lower rates, same quality</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rate Tier Visualization */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="border-amber-300 bg-amber-50">
|
||||
<CardContent className="p-5 text-center">
|
||||
<div className="w-12 h-12 bg-amber-500 rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<DollarSign className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-slate-900">Standard Tier</h4>
|
||||
<p className="text-3xl font-bold text-amber-600 mt-2">
|
||||
${(metrics.avgNonContractedRate || 52).toFixed(0)}/hr
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Higher rates • Less volume</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-[#0A39DF] border-2 bg-blue-50">
|
||||
<CardContent className="p-5 text-center relative">
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-[#0A39DF] text-white">
|
||||
OPTIMIZE
|
||||
</Badge>
|
||||
<div className="w-12 h-12 bg-[#0A39DF] rounded-lg flex items-center justify-center mx-auto mb-3 mt-1">
|
||||
<ArrowRight className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-slate-900">Ready to Optimize</h4>
|
||||
<p className="text-3xl font-bold text-[#0A39DF] mt-2">
|
||||
{totalCount} {countLabel}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Eligible for optimization</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-300 bg-emerald-50">
|
||||
<CardContent className="p-5 text-center">
|
||||
<div className="w-12 h-12 bg-emerald-600 rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-slate-900">Preferred Tier</h4>
|
||||
<p className="text-3xl font-bold text-emerald-600 mt-2">
|
||||
${(metrics.avgContractedRate || 42).toFixed(0)}/hr
|
||||
</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Better rates • Volume discounts</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Optimization Opportunity Cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{opportunities.map((opportunity) => {
|
||||
const colors = priorityColors[opportunity.priority];
|
||||
|
||||
return (
|
||||
<Card key={opportunity.id} className={`${colors.bg} ${colors.border} border-2 hover:shadow-lg transition-all`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={colors.badge}>
|
||||
{opportunity.priority === "high" ? "HIGH IMPACT" : "MEDIUM IMPACT"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-white">
|
||||
{opportunity.tierFrom} → {opportunity.tierTo}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className={`text-lg ${colors.text}`}>{opportunity.category}</CardTitle>
|
||||
<p className="text-sm text-slate-500 mt-1">{opportunity.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Rate Comparison */}
|
||||
<div className="bg-white rounded-lg p-3 border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-700">Rate Comparison</span>
|
||||
<Badge className="bg-green-100 text-green-700">
|
||||
Save {opportunity.savingsPercent}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-slate-500">Current</p>
|
||||
<p className="text-lg font-bold text-amber-600">${opportunity.currentRate}/hr</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-purple-500" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-slate-500">Optimized</p>
|
||||
<p className="text-lg font-bold text-emerald-600">${opportunity.targetRate}/hr</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-white/70 rounded-lg p-2">
|
||||
<p className="text-slate-500 text-xs">Current Spend</p>
|
||||
<p className="font-bold text-slate-900">${opportunity.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
</div>
|
||||
<div className="bg-white/70 rounded-lg p-2">
|
||||
<p className="text-slate-500 text-xs">Annual Savings</p>
|
||||
<p className="font-bold text-green-600">${opportunity.potentialSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-600">{opportunity.count} {opportunity.countLabel} eligible</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opportunity.benefits.map((benefit, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs bg-white">
|
||||
<CheckCircle className="w-3 h-3 mr-1 text-green-500" />
|
||||
{benefit}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button className="w-full bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => setSelectedOpportunity(opportunity)}>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Optimize Now
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Vendor Recommendations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-amber-500" />
|
||||
Recommended Vendors for Conversion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{vendors.slice(0, 3).map((vendor, idx) => (
|
||||
<div key={vendor.id || idx} className="p-4 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{vendor.legal_name || `Preferred Vendor ${idx + 1}`}</p>
|
||||
<p className="text-xs text-slate-500">{vendor.region || "National"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Avg Rate</span>
|
||||
<span className="font-medium">${(metrics.avgContractedRate - idx * 2).toFixed(2)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Reliability</span>
|
||||
<span className="font-medium text-green-600">{95 - idx}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Workforce</span>
|
||||
<span className="font-medium">{vendor.workforce_count || 150 - idx * 20}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConversionModal
|
||||
open={!!selectedOpportunity}
|
||||
onClose={() => setSelectedOpportunity(null)}
|
||||
opportunity={selectedOpportunity}
|
||||
vendors={vendors}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Target, Users, DollarSign, CheckCircle, ArrowRight,
|
||||
Building2, Clock, Sparkles, AlertTriangle, Send, Shield,
|
||||
FileText, MapPin, Phone, Mail, Star, XCircle, UserPlus,
|
||||
Briefcase, CheckSquare, AlertCircle
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ConversionModal({ open, onClose, opportunity, vendors = [], userRole }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState(1);
|
||||
const [conversionType, setConversionType] = useState(""); // "existing" or "new"
|
||||
const [selectedVendor, setSelectedVendor] = useState("");
|
||||
const [selectedWorkers, setSelectedWorkers] = useState([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [urgency, setUrgency] = useState("normal");
|
||||
|
||||
// New vendor referral fields
|
||||
const [newVendor, setNewVendor] = useState({
|
||||
legal_name: "",
|
||||
contact_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
region: "",
|
||||
services: "",
|
||||
reason: "",
|
||||
estimated_rate: "",
|
||||
});
|
||||
|
||||
// For Clients: Mock workers for the opportunity
|
||||
const affectedWorkers = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
|
||||
id: `worker-${i + 1}`,
|
||||
name: `Worker ${i + 1}`,
|
||||
role: ["Server", "Bartender", "Cook", "Host", "Busser"][i % 5],
|
||||
currentRate: 22 + Math.random() * 10,
|
||||
assignments: Math.floor(Math.random() * 20) + 5,
|
||||
}));
|
||||
|
||||
// For Procurement: Mock vendors to consolidate
|
||||
const affectedVendors = Array.from({ length: opportunity?.count || 5 }, (_, i) => ({
|
||||
id: `vendor-aff-${i + 1}`,
|
||||
name: ["Bay Area Events", "Quick Staff Inc.", "Metro Workforce", "City Staffing Co.", "Premier Temps"][i % 5],
|
||||
tier: i < 2 ? "Tier 1" : i < 4 ? "Tier 2" : "Tier 3",
|
||||
currentSpend: 15000 + Math.random() * 20000,
|
||||
rate: 45 + Math.random() * 15,
|
||||
reliability: 75 + Math.random() * 20,
|
||||
}));
|
||||
|
||||
const isProcurement = userRole === "procurement" || userRole === "admin" || userRole === "operator";
|
||||
|
||||
const createTaskMutation = useMutation({
|
||||
mutationFn: (taskData) => base44.entities.Task.create(taskData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const createVendorInviteMutation = useMutation({
|
||||
mutationFn: (inviteData) => base44.entities.VendorInvite.create(inviteData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vendor-invites'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
setStep(1);
|
||||
setConversionType("");
|
||||
setSelectedVendor("");
|
||||
setSelectedWorkers([]);
|
||||
setNotes("");
|
||||
setUrgency("normal");
|
||||
setNewVendor({
|
||||
legal_name: "",
|
||||
contact_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
region: "",
|
||||
services: "",
|
||||
reason: "",
|
||||
estimated_rate: "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (conversionType === "new") {
|
||||
// Create Vendor Referral Task for Procurement
|
||||
const referralTask = {
|
||||
task_name: `Vendor Referral: ${newVendor.legal_name}`,
|
||||
team_id: "procurement",
|
||||
description: `**New Vendor Referral Request**\n\n**Vendor Details:**\n- Company: ${newVendor.legal_name}\n- Contact: ${newVendor.contact_name}\n- Email: ${newVendor.email}\n- Phone: ${newVendor.phone}\n- Region: ${newVendor.region}\n\n**Services Needed:** ${newVendor.services}\n\n**Estimated Rate:** ${newVendor.estimated_rate}\n\n**Reason for Request:** ${newVendor.reason}\n\n**Conversion Opportunity:** ${opportunity?.category}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n**Workers to Convert:** ${selectedWorkers.length}\n\n**Urgency:** ${urgency}\n\n**Additional Notes:** ${notes}\n\n---\n**Action Required:**\n1. Verify vendor credentials\n2. Review insurance & compliance docs\n3. Evaluate rate alignment\n4. Approve/Reject for Preferred Network`,
|
||||
status: "pending",
|
||||
priority: urgency === "urgent" ? "high" : "normal",
|
||||
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// Also create vendor invite record
|
||||
const vendorInvite = {
|
||||
vendor_name: newVendor.legal_name,
|
||||
contact_name: newVendor.contact_name,
|
||||
email: newVendor.email,
|
||||
phone: newVendor.phone,
|
||||
status: "pending_review",
|
||||
referral_reason: newVendor.reason,
|
||||
services_requested: newVendor.services,
|
||||
region: newVendor.region,
|
||||
notes: `Referred via Conversion Engine. Category: ${opportunity?.category}. Potential Savings: $${opportunity?.potentialSavings?.toLocaleString()}`,
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
createTaskMutation.mutateAsync(referralTask),
|
||||
createVendorInviteMutation.mutateAsync(vendorInvite),
|
||||
]);
|
||||
|
||||
toast({
|
||||
title: "Vendor Referral Submitted",
|
||||
description: `${newVendor.legal_name} has been sent to Procurement for review and approval.`,
|
||||
});
|
||||
} else {
|
||||
// Existing vendor conversion
|
||||
const taskData = {
|
||||
task_name: isProcurement
|
||||
? `Vendor Consolidation: ${opportunity?.category}`
|
||||
: `Rate Optimization: ${opportunity?.category}`,
|
||||
team_id: "procurement",
|
||||
description: isProcurement
|
||||
? `**Vendor Consolidation Request**\n\n**Category:** ${opportunity?.category}\n**Target Tier 1 Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Vendors to Consolidate:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Review vendor spend analysis\n2. Negotiate volume discounts\n3. Migrate contracts to preferred vendor\n4. Track consolidated savings`
|
||||
: `**Staff Rate Optimization**\n\n**Category:** ${opportunity?.category}\n**Target Vendor:** ${vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}\n**Positions Selected:** ${selectedWorkers.length}\n**Potential Savings:** $${opportunity?.potentialSavings?.toLocaleString()}\n\n**Urgency:** ${urgency}\n\n**Notes:** ${notes}\n\n---\n**Action Required:**\n1. Confirm rate book assignment\n2. Update position assignments\n3. Track savings in dashboard`,
|
||||
status: "pending",
|
||||
priority: urgency === "urgent" ? "high" : "normal",
|
||||
due_date: new Date(Date.now() + (urgency === "urgent" ? 3 : 7) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
await createTaskMutation.mutateAsync(taskData);
|
||||
|
||||
toast({
|
||||
title: isProcurement ? "Vendor Consolidation Submitted" : "Optimization Request Submitted",
|
||||
description: isProcurement
|
||||
? `${selectedWorkers.length} vendors will be consolidated to ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`
|
||||
: `${selectedWorkers.length} positions will be optimized with ${vendors.find(v => v.id === selectedVendor)?.legal_name || "preferred vendor"}.`,
|
||||
});
|
||||
}
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const toggleItem = (itemId) => {
|
||||
setSelectedWorkers(prev =>
|
||||
prev.includes(itemId)
|
||||
? prev.filter(id => id !== itemId)
|
||||
: [...prev, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
const selectAllItems = () => {
|
||||
const items = isProcurement ? affectedVendors : affectedWorkers;
|
||||
if (selectedWorkers.length === items.length) {
|
||||
setSelectedWorkers([]);
|
||||
} else {
|
||||
setSelectedWorkers(items.map(w => w.id));
|
||||
}
|
||||
};
|
||||
|
||||
if (!opportunity) return null;
|
||||
|
||||
const totalSteps = conversionType === "new" ? 4 : 3;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-[#0A39DF]" />
|
||||
{conversionType === "new" ? "New Vendor Referral" : "Start Conversion"}: {opportunity.category}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((s) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
step >= s ? 'bg-[#0A39DF] text-white' : 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{step > s ? <CheckCircle className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
{s < totalSteps && (
|
||||
<div className={`flex-1 h-1 rounded ${step > s ? 'bg-[#0A39DF]' : 'bg-slate-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Choose Conversion Type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 p-4 rounded-xl border border-blue-100">
|
||||
<h4 className="font-semibold text-slate-900 mb-3">Conversion Opportunity Summary</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Current Spend</p>
|
||||
<p className="text-lg font-bold text-slate-900">
|
||||
${opportunity.currentSpend?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Potential Savings</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
${opportunity.potentialSavings?.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">{isProcurement ? "Vendors Affected" : "Positions Affected"}</p>
|
||||
<p className="text-lg font-bold text-slate-900">{opportunity.count} {opportunity.countLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Savings Rate</p>
|
||||
<p className="text-lg font-bold text-amber-600">{opportunity.savingsPercent}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-semibold text-slate-900">How would you like to convert?</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
conversionType === "existing" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => setConversionType("existing")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-semibold text-slate-900">Preferred Vendor Network</h5>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Convert to an already approved vendor in your network
|
||||
</p>
|
||||
<Badge className="mt-2 bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Pre-Approved
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
conversionType === "new" ? 'ring-2 ring-[#0A39DF] bg-blue-50' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => setConversionType("new")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<UserPlus className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-semibold text-slate-900">New Vendor Referral</h5>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Refer a new vendor for procurement approval
|
||||
</p>
|
||||
<Badge className="mt-2 bg-amber-100 text-amber-700">
|
||||
<AlertCircle className="w-3 h-3 mr-1" />
|
||||
Requires Approval
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{conversionType === "new" && (
|
||||
<div className="bg-amber-50 p-4 rounded-xl border border-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">Vendor Referral Workflow</p>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
This vendor will be flagged as "Not Yet Approved" and sent to Procurement for review.
|
||||
They'll evaluate pricing, insurance, compliance, and SLA capability before approval.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 for New Vendor: Vendor Details */}
|
||||
{step === 2 && conversionType === "new" && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Vendor Referral Form</strong> — This will be sent to Procurement for review
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Company Name *</label>
|
||||
<Input
|
||||
value={newVendor.legal_name}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, legal_name: e.target.value })}
|
||||
placeholder="e.g., Bay Area Staffing Co."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Contact Name *</label>
|
||||
<Input
|
||||
value={newVendor.contact_name}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, contact_name: e.target.value })}
|
||||
placeholder="e.g., John Smith"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Email *</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
value={newVendor.email}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, email: e.target.value })}
|
||||
placeholder="vendor@company.com"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Phone</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
value={newVendor.phone}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, phone: e.target.value })}
|
||||
placeholder="(555) 123-4567"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Region / Location</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
value={newVendor.region}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, region: e.target.value })}
|
||||
placeholder="e.g., Bay Area, Los Angeles"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Estimated Hourly Rate</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input
|
||||
value={newVendor.estimated_rate}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, estimated_rate: e.target.value })}
|
||||
placeholder="e.g., $35-45/hr"
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Services Needed *</label>
|
||||
<Textarea
|
||||
value={newVendor.services}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, services: e.target.value })}
|
||||
placeholder="e.g., Event catering staff, bartenders, servers for corporate events..."
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Reason for Request *</label>
|
||||
<Textarea
|
||||
value={newVendor.reason}
|
||||
onChange={(e) => setNewVendor({ ...newVendor, reason: e.target.value })}
|
||||
placeholder="Why do you need this vendor? e.g., Local specialist, unique skills, better coverage in specific area..."
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2 for Existing / Step 3 for New: Select Items */}
|
||||
{((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && (
|
||||
<div className="space-y-4">
|
||||
{conversionType === "existing" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
{isProcurement ? "Select Target Preferred Vendor" : "Select Preferred Vendor"}
|
||||
</label>
|
||||
<Select value={selectedVendor} onValueChange={setSelectedVendor}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose from approved vendors" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.length > 0 ? vendors.filter(v => v.approval_status === "approved" || v.is_active).map((vendor) => (
|
||||
<SelectItem key={vendor.id} value={vendor.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
{vendor.legal_name || vendor.doing_business_as}
|
||||
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)) : (
|
||||
<>
|
||||
<SelectItem value="vendor-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
Premier Staffing Co.
|
||||
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="vendor-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
Elite Workforce Solutions
|
||||
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="vendor-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
Compass Preferred Partners
|
||||
<Badge className="ml-2 bg-emerald-100 text-emerald-700 text-[10px]">Tier 1</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Procurement: Select Vendors to Consolidate */}
|
||||
{isProcurement ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Select Vendors to Consolidate ({selectedWorkers.length}/{affectedVendors.length})
|
||||
</label>
|
||||
<Button variant="ghost" size="sm" onClick={selectAllItems}>
|
||||
{selectedWorkers.length === affectedVendors.length ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{affectedVendors.map((vendor) => (
|
||||
<div
|
||||
key={vendor.id}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
|
||||
selectedWorkers.includes(vendor.id) ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
onClick={() => toggleItem(vendor.id)}
|
||||
>
|
||||
<Checkbox checked={selectedWorkers.includes(vendor.id)} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{vendor.name}</p>
|
||||
<Badge className={`text-[10px] ${
|
||||
vendor.tier === "Tier 1" ? "bg-emerald-100 text-emerald-700" :
|
||||
vendor.tier === "Tier 2" ? "bg-amber-100 text-amber-700" :
|
||||
"bg-red-100 text-red-700"
|
||||
}`}>{vendor.tier}</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">${vendor.currentSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
<p className="text-xs text-slate-500">${vendor.rate.toFixed(0)}/hr avg</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Client: Select Workers/Positions */
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-slate-700">
|
||||
Select Positions to Optimize ({selectedWorkers.length}/{affectedWorkers.length})
|
||||
</label>
|
||||
<Button variant="ghost" size="sm" onClick={selectAllItems}>
|
||||
{selectedWorkers.length === affectedWorkers.length ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{affectedWorkers.map((worker) => (
|
||||
<div
|
||||
key={worker.id}
|
||||
className={`flex items-center gap-3 p-3 cursor-pointer hover:bg-slate-50 ${
|
||||
selectedWorkers.includes(worker.id) ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
onClick={() => toggleItem(worker.id)}
|
||||
>
|
||||
<Checkbox checked={selectedWorkers.includes(worker.id)} />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">{worker.name}</p>
|
||||
<p className="text-xs text-slate-500">{worker.role}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">${worker.currentRate.toFixed(2)}/hr</p>
|
||||
<p className="text-xs text-slate-500">{worker.assignments} shifts</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Step: Confirm & Submit */}
|
||||
{((step === 3 && conversionType === "existing") || (step === 4 && conversionType === "new")) && (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
conversionType === "new"
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: 'bg-emerald-50 border-emerald-200'
|
||||
}`}>
|
||||
<h4 className={`font-semibold mb-2 flex items-center gap-2 ${
|
||||
conversionType === "new" ? 'text-amber-900' : 'text-emerald-900'
|
||||
}`}>
|
||||
{conversionType === "new" ? (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Vendor Referral Summary
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Conversion Request Summary
|
||||
</>
|
||||
)}
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p><strong>Category:</strong> {opportunity.category}</p>
|
||||
<p><strong>{isProcurement ? "Vendors" : "Positions"} Selected:</strong> {selectedWorkers.length}</p>
|
||||
{conversionType === "new" ? (
|
||||
<>
|
||||
<p><strong>New Vendor:</strong> {newVendor.legal_name}</p>
|
||||
<p><strong>Contact:</strong> {newVendor.contact_name} ({newVendor.email})</p>
|
||||
<p><strong>Status:</strong> <Badge className="bg-amber-100 text-amber-700">Pending Procurement Review</Badge></p>
|
||||
</>
|
||||
) : (
|
||||
<p><strong>Target Vendor:</strong> {vendors.find(v => v.id === selectedVendor)?.legal_name || selectedVendor}</p>
|
||||
)}
|
||||
<p><strong>Estimated Savings:</strong> <span className="text-green-600 font-bold">${Math.round((opportunity.potentialSavings || 0) * (selectedWorkers.length / Math.max(opportunity.count || 1, 1))).toLocaleString()} ({opportunity.savingsPercent || 0}%)</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conversionType === "new" && (
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
|
||||
<h5 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
|
||||
<Briefcase className="w-4 h-4" />
|
||||
What Happens Next?
|
||||
</h5>
|
||||
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
|
||||
<li>Vendor referral sent to Procurement team</li>
|
||||
<li>Procurement evaluates pricing, insurance & compliance</li>
|
||||
<li>Vendor approved → added to Preferred Network</li>
|
||||
<li>Spot labor automatically converts to contracted savings</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
Urgency Level
|
||||
</label>
|
||||
<Select value={urgency} onValueChange={setUrgency}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
Low — Within 2 weeks
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
Normal — Within 1 week
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="urgent">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
Urgent — Within 3 days
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-2 block">
|
||||
Additional Notes (Optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any special requirements or instructions..."
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex gap-2 mt-6">
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{step === 1 ? (
|
||||
<Button
|
||||
onClick={() => setStep(2)}
|
||||
className="bg-[#0A39DF] hover:bg-[#0831b8]"
|
||||
disabled={!conversionType}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : step < totalSteps ? (
|
||||
<Button
|
||||
onClick={() => setStep(step + 1)}
|
||||
className="bg-[#0A39DF] hover:bg-[#0831b8]"
|
||||
disabled={
|
||||
(step === 2 && conversionType === "new" && (!newVendor.legal_name || !newVendor.email || !newVendor.services || !newVendor.reason)) ||
|
||||
((step === 2 && conversionType === "existing") || (step === 3 && conversionType === "new")) && selectedWorkers.length === 0
|
||||
}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className={conversionType === "new" ? "bg-amber-600 hover:bg-amber-700" : "bg-emerald-600 hover:bg-emerald-700"}
|
||||
disabled={createTaskMutation.isPending || createVendorInviteMutation.isPending}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{createTaskMutation.isPending || createVendorInviteMutation.isPending
|
||||
? "Submitting..."
|
||||
: conversionType === "new"
|
||||
? "Submit Vendor Referral"
|
||||
: "Submit Conversion Request"
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
TrendingUp, TrendingDown, DollarSign, Users, Target,
|
||||
CheckCircle, AlertTriangle, Clock, BarChart3, PieChart,
|
||||
ArrowUpRight, Zap, Shield, Star, Activity, Calendar,
|
||||
Package, Award, MapPin, Building2
|
||||
} from "lucide-react";
|
||||
import { PieChart as RechartsPie, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||
|
||||
export default function DynamicSavingsDashboard({ metrics, projections, timeRange, userRole }) {
|
||||
const getTimeLabel = () => {
|
||||
switch (timeRange) {
|
||||
case "7days": return "7-Day";
|
||||
case "30days": return "30-Day";
|
||||
case "quarter": return "Quarterly";
|
||||
case "year": return "Annual";
|
||||
default: return "30-Day";
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectedSavings = () => {
|
||||
switch (timeRange) {
|
||||
case "7days": return projections.sevenDays;
|
||||
case "30days": return projections.thirtyDays;
|
||||
case "quarter": return projections.quarter;
|
||||
case "year": return projections.year;
|
||||
default: return projections.thirtyDays;
|
||||
}
|
||||
};
|
||||
|
||||
// Role-specific content
|
||||
const getRoleContent = () => {
|
||||
switch (userRole) {
|
||||
case "procurement":
|
||||
return {
|
||||
mainTitle: "Vendor Network Performance",
|
||||
mainSubtitle: "Optimize your preferred vendor network",
|
||||
chartTitle: "Vendor Tier Distribution",
|
||||
chartData: [
|
||||
{ name: "Preferred Vendors", value: 40, color: "#22c55e" },
|
||||
{ name: "Approved Vendors", value: 35, color: "#0A39DF" },
|
||||
{ name: "Standard Vendors", value: 25, color: "#f59e0b" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Avg Vendor Score", current: `${(metrics.avgReliability + 5).toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability - 5).toFixed(0)}%`, trend: "up", icon: Award },
|
||||
{ label: "Contract Compliance", current: `${metrics.contractedRatio.toFixed(0)}%`, optimized: "90%", savings: `+${(90 - metrics.contractedRatio).toFixed(0)}%`, trend: "up", icon: Shield },
|
||||
{ label: "Rate Variance", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
|
||||
{ label: "SLA Adherence", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: CheckCircle },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Upgrade 3 high-performing Standard vendors to Approved tier", impact: "Better rates, guaranteed capacity", deadline: "This week" },
|
||||
{ priority: "high", action: "Renegotiate rates with top 5 Preferred vendors", impact: `$${(getProjectedSavings() * 0.3).toLocaleString()} potential savings`, deadline: "This month" },
|
||||
{ priority: "medium", action: "Review vendors below 85% SLA adherence", impact: "Improve network reliability", deadline: "Next 2 weeks" },
|
||||
],
|
||||
};
|
||||
|
||||
case "operator":
|
||||
return {
|
||||
mainTitle: "Enterprise Operational Efficiency",
|
||||
mainSubtitle: "Cross-sector performance optimization",
|
||||
chartTitle: "Spend by Sector",
|
||||
chartData: [
|
||||
{ name: "Food Service", value: 45, color: "#0A39DF" },
|
||||
{ name: "Events", value: 30, color: "#8b5cf6" },
|
||||
{ name: "Facilities", value: 15, color: "#06b6d4" },
|
||||
{ name: "Other", value: 10, color: "#f59e0b" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
|
||||
{ label: "Labor Utilization", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
|
||||
{ label: "Cost Efficiency", current: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`, optimized: `$${((metrics.totalSpend / Math.max(metrics.completedOrders, 1)) * 0.85).toFixed(0)}`, savings: "-15%", trend: "down", icon: DollarSign },
|
||||
{ label: "Overtime Rate", current: "12%", optimized: "5%", savings: "-7%", trend: "down", icon: Clock },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Balance workforce across underperforming sectors", impact: "Improve fill rate by 8%", deadline: "This week" },
|
||||
{ priority: "high", action: "Implement predictive scheduling for peak periods", impact: "Reduce overtime by 40%", deadline: "Next 2 weeks" },
|
||||
{ priority: "medium", action: "Cross-train staff for multi-sector flexibility", impact: "Increase utilization 15%", deadline: "This quarter" },
|
||||
],
|
||||
};
|
||||
|
||||
case "sector":
|
||||
return {
|
||||
mainTitle: "Location Performance",
|
||||
mainSubtitle: "Your site's staffing efficiency",
|
||||
chartTitle: "Position Coverage",
|
||||
chartData: [
|
||||
{ name: "Filled Positions", value: metrics.fillRate, color: "#22c55e" },
|
||||
{ name: "Open Gaps", value: 100 - metrics.fillRate, color: "#ef4444" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Position Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
|
||||
{ label: "Staff Attendance", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: CheckCircle },
|
||||
{ label: "Shift Coverage", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Calendar },
|
||||
{ label: "Response Time", current: "4 hrs", optimized: "1 hr", savings: "-3 hrs", trend: "down", icon: Clock },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Fill 3 critical morning shift gaps", impact: "100% coverage for peak hours", deadline: "Tomorrow" },
|
||||
{ priority: "high", action: "Request backup staff for weekend events", impact: "Prevent no-show impact", deadline: "This week" },
|
||||
{ priority: "medium", action: "Train cross-functional backup team", impact: "Reduce gap response time", deadline: "This month" },
|
||||
],
|
||||
};
|
||||
|
||||
case "client":
|
||||
return {
|
||||
mainTitle: "Your Event Coverage & Savings",
|
||||
mainSubtitle: "Maximize staffing quality while reducing costs",
|
||||
chartTitle: "Event Fulfillment",
|
||||
chartData: [
|
||||
{ name: "Fully Staffed", value: metrics.fillRate, color: "#22c55e" },
|
||||
{ name: "Partial Coverage", value: Math.max(0, 100 - metrics.fillRate - 5), color: "#f59e0b" },
|
||||
{ name: "Gaps", value: Math.min(5, 100 - metrics.fillRate), color: "#ef4444" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Event Coverage", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "100%", savings: `+${(100 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Calendar },
|
||||
{ label: "Staff Quality", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Star },
|
||||
{ label: "Cost Savings", current: `$${metrics.avgNonContractedRate.toFixed(0)}/hr`, optimized: `$${metrics.avgContractedRate.toFixed(0)}/hr`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(0)}/hr`, trend: "down", icon: DollarSign },
|
||||
{ label: "On-Time Arrival", current: `${(100 - metrics.noShowRate).toFixed(0)}%`, optimized: "99%", savings: `+${(99 - (100 - metrics.noShowRate)).toFixed(0)}%`, trend: "up", icon: Clock },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Book preferred vendors for upcoming events", impact: `Save $${(getProjectedSavings() * 0.2).toLocaleString()} on rates`, deadline: "Before next event" },
|
||||
{ priority: "medium", action: "Request top-rated staff from past events", impact: "Improve service quality 20%", deadline: "Next booking" },
|
||||
{ priority: "medium", action: "Bundle multiple events for volume discount", impact: "Additional 10% savings", deadline: "This month" },
|
||||
],
|
||||
};
|
||||
|
||||
case "vendor":
|
||||
return {
|
||||
mainTitle: "Your Competitive Performance",
|
||||
mainSubtitle: "Stand out in the vendor network",
|
||||
chartTitle: "Your Performance vs. Network",
|
||||
chartData: [
|
||||
{ name: "Your Score", value: metrics.avgReliability, color: "#0A39DF" },
|
||||
{ name: "Network Avg", value: 100 - metrics.avgReliability, color: "#e2e8f0" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(0)}%`, optimized: "99%", savings: `+${(99 - metrics.fillRate).toFixed(0)}%`, trend: "up", icon: Target },
|
||||
{ label: "Team Reliability", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "98%", savings: `+${(98 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Users },
|
||||
{ label: "Client Value", current: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`, optimized: `$${((metrics.avgNonContractedRate - metrics.avgContractedRate) * 1.2).toFixed(2)}/hr`, savings: "+20%", trend: "up", icon: Zap },
|
||||
{ label: "Response Time", current: "2 hrs", optimized: "30 min", savings: "-1.5 hrs", trend: "down", icon: Clock },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Improve response time to order requests", impact: "Win 15% more orders", deadline: "Immediate" },
|
||||
{ priority: "high", action: "Reduce no-show rate below 2%", impact: "Upgrade to Preferred tier", deadline: "This month" },
|
||||
{ priority: "medium", action: "Expand certified workforce pool", impact: "Access premium assignments", deadline: "This quarter" },
|
||||
],
|
||||
};
|
||||
|
||||
default: // admin
|
||||
return {
|
||||
mainTitle: "Platform-Wide Savings",
|
||||
mainSubtitle: "Transform labor spend into strategic advantage",
|
||||
chartTitle: "Labor Spend Mix",
|
||||
chartData: [
|
||||
{ name: "Contracted Labor", value: metrics.contractedSpend, color: "#22c55e" },
|
||||
{ name: "Non-Contracted", value: metrics.nonContractedSpend, color: "#ef4444" },
|
||||
],
|
||||
kpis: [
|
||||
{ label: "Cost per Hour", current: `$${metrics.avgNonContractedRate.toFixed(2)}`, optimized: `$${metrics.avgContractedRate.toFixed(2)}`, savings: `-$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`, trend: "down", icon: DollarSign },
|
||||
{ label: "Fill Rate", current: `${metrics.fillRate.toFixed(1)}%`, optimized: "98%", savings: `+${(98 - metrics.fillRate).toFixed(1)}%`, trend: "up", icon: Target },
|
||||
{ label: "No-Show Rate", current: `${metrics.noShowRate.toFixed(1)}%`, optimized: "1%", savings: `-${(metrics.noShowRate - 1).toFixed(1)}%`, trend: "down", icon: AlertTriangle },
|
||||
{ label: "Reliability Score", current: `${metrics.avgReliability.toFixed(0)}%`, optimized: "95%", savings: `+${(95 - metrics.avgReliability).toFixed(0)}%`, trend: "up", icon: Shield },
|
||||
],
|
||||
actions: [
|
||||
{ priority: "high", action: "Convert 15 high-volume gig workers to preferred vendor", impact: `$${(getProjectedSavings() * 0.25).toLocaleString()} savings`, deadline: "This week" },
|
||||
{ priority: "high", action: "Negotiate volume discount with top 3 vendors", impact: `$${(getProjectedSavings() * 0.2).toLocaleString()} savings`, deadline: "Next 2 weeks" },
|
||||
{ priority: "medium", action: "Reduce overtime through better scheduling", impact: `$${(getProjectedSavings() * 0.15).toLocaleString()} savings`, deadline: "This month" },
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const content = getRoleContent();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Main Summary */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white border-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<p className="text-blue-200 text-sm font-medium mb-1">{content.mainTitle}</p>
|
||||
<p className="text-4xl font-bold">${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
<p className="text-blue-200 mt-2">{content.mainSubtitle}</p>
|
||||
</div>
|
||||
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
|
||||
<TrendingUp className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{content.kpis.slice(0, 4).map((kpi, idx) => (
|
||||
<div key={idx} className="bg-white/10 rounded-lg p-3">
|
||||
<p className="text-blue-200 text-xs">{kpi.label}</p>
|
||||
<p className="text-xl font-bold">{kpi.current}</p>
|
||||
<p className="text-xs text-green-300">{kpi.savings}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PieChart className="w-5 h-5 text-slate-400" />
|
||||
{content.chartTitle}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPie>
|
||||
<Pie
|
||||
data={content.chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{content.chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => typeof value === 'number' && value > 100 ? `$${value.toLocaleString()}` : `${value}%`} />
|
||||
</RechartsPie>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||
{content.chartData.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-slate-600">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* KPI Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-blue-500" />
|
||||
Performance Optimization Metrics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{content.kpis.map((kpi, idx) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className="w-5 h-5 text-slate-400" />
|
||||
<span className="font-medium text-slate-700">{kpi.label}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-slate-500">Current</span>
|
||||
<span className="font-semibold text-slate-900">{kpi.current}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-slate-500">Target</span>
|
||||
<span className="font-semibold text-green-600">{kpi.optimized}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-slate-500">Impact</span>
|
||||
<Badge className={kpi.trend === "up" ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700"}>
|
||||
{kpi.trend === "up" ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
|
||||
{kpi.savings}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-500" />
|
||||
Priority Actions for You
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{content.actions.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-4 rounded-xl border-2 flex items-center justify-between ${
|
||||
item.priority === "high"
|
||||
? "bg-red-50 border-red-200"
|
||||
: "bg-amber-50 border-amber-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${item.priority === "high" ? "bg-red-500" : "bg-amber-500"}`} />
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{item.action}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
{item.impact}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{item.deadline}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={item.priority === "high" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
|
||||
{item.priority.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Users, DollarSign, Clock, TrendingUp, TrendingDown,
|
||||
Building2, MapPin, Briefcase, AlertTriangle, CheckCircle
|
||||
} from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts";
|
||||
|
||||
export default function LaborSpendAnalysis({ assignments, workforce, orders, metrics, userRole }) {
|
||||
// Generate channel breakdown
|
||||
const channelData = [
|
||||
{ channel: "Preferred Vendors", spend: metrics.contractedSpend * 0.6, workers: Math.floor(workforce.length * 0.4), efficiency: 94 },
|
||||
{ channel: "Approved Vendors", spend: metrics.contractedSpend * 0.4, workers: Math.floor(workforce.length * 0.25), efficiency: 88 },
|
||||
{ channel: "Gig Platforms", spend: metrics.nonContractedSpend * 0.5, workers: Math.floor(workforce.length * 0.2), efficiency: 72 },
|
||||
{ channel: "Agency Labor", spend: metrics.nonContractedSpend * 0.3, workers: Math.floor(workforce.length * 0.1), efficiency: 68 },
|
||||
{ channel: "Internal Pool", spend: metrics.nonContractedSpend * 0.2, workers: Math.floor(workforce.length * 0.05), efficiency: 91 },
|
||||
];
|
||||
|
||||
const utilizationData = [
|
||||
{ category: "Culinary Staff", utilized: 85, available: 100, cost: 45000 },
|
||||
{ category: "Event Staff", utilized: 78, available: 100, cost: 32000 },
|
||||
{ category: "Bartenders", utilized: 92, available: 100, cost: 28000 },
|
||||
{ category: "Security", utilized: 65, available: 100, cost: 18000 },
|
||||
{ category: "Facilities", utilized: 71, available: 100, cost: 15000 },
|
||||
];
|
||||
|
||||
const benchmarkData = [
|
||||
{ metric: "Cost per Hour", yours: metrics.avgNonContractedRate, benchmark: 42.50, industry: 48.00 },
|
||||
{ metric: "Fill Rate", yours: metrics.fillRate, benchmark: 95, industry: 88 },
|
||||
{ metric: "No-Show Rate", yours: metrics.noShowRate, benchmark: 2, industry: 5 },
|
||||
{ metric: "OT Percentage", yours: 12, benchmark: 8, industry: 15 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Channel Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-blue-500" />
|
||||
Labor Spend by Channel
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead>Channel</TableHead>
|
||||
<TableHead className="text-right">Spend</TableHead>
|
||||
<TableHead className="text-right">Workers</TableHead>
|
||||
<TableHead className="text-right">Efficiency</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channelData.map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="font-medium">{row.channel}</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
${row.spend.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{row.workers}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Progress value={row.efficiency} className="w-16 h-2" />
|
||||
<span className="text-sm font-medium">{row.efficiency}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={row.efficiency >= 85 ? "bg-green-100 text-green-700" : row.efficiency >= 70 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}>
|
||||
{row.efficiency >= 85 ? "Optimal" : row.efficiency >= 70 ? "Review" : "Optimize"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Utilization Chart */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-purple-500" />
|
||||
Workforce Utilization by Category
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={utilizationData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis type="number" domain={[0, 100]} stroke="#64748b" fontSize={12} />
|
||||
<YAxis type="category" dataKey="category" stroke="#64748b" fontSize={12} width={100} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`${value}%`, 'Utilization']}
|
||||
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
|
||||
/>
|
||||
<Bar dataKey="utilized" fill="#0A39DF" radius={[0, 4, 4, 0]} name="Utilized" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5 text-green-500" />
|
||||
Cost by Category
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{utilizationData.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-[#0A39DF] rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{item.category}</p>
|
||||
<p className="text-sm text-slate-500">{item.utilized}% utilized</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-slate-900">${item.cost.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-500">monthly spend</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Benchmarking */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-amber-500" />
|
||||
Performance Benchmarking
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{benchmarkData.map((item, idx) => {
|
||||
const isBetter = item.metric === "No-Show Rate" || item.metric === "OT Percentage"
|
||||
? item.yours < item.benchmark
|
||||
: item.yours > item.benchmark;
|
||||
|
||||
return (
|
||||
<div key={idx} className="p-4 bg-slate-50 rounded-xl">
|
||||
<p className="text-sm text-slate-500 mb-2">{item.metric}</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Your Rate</span>
|
||||
<span className={`font-bold ${isBetter ? "text-green-600" : "text-red-600"}`}>
|
||||
{item.metric.includes("Rate") || item.metric.includes("Percentage")
|
||||
? `${item.yours.toFixed(1)}%`
|
||||
: `$${item.yours.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Benchmark</span>
|
||||
<span className="font-medium text-slate-900">
|
||||
{item.metric.includes("Rate") || item.metric.includes("Percentage")
|
||||
? `${item.benchmark}%`
|
||||
: `$${item.benchmark.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-200">
|
||||
<span className="text-sm text-slate-600">Industry Avg</span>
|
||||
<span className="text-slate-500">
|
||||
{item.metric.includes("Rate") || item.metric.includes("Percentage")
|
||||
? `${item.industry}%`
|
||||
: `$${item.industry.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Badge className={isBetter ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700"}>
|
||||
{isBetter ? <CheckCircle className="w-3 h-3 mr-1" /> : <AlertTriangle className="w-3 h-3 mr-1" />}
|
||||
{isBetter ? "Above Benchmark" : "Below Benchmark"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contracted vs Non-Contracted Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contracted vs. Non-Contracted Labor Analysis</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-6 bg-green-50 rounded-xl border-2 border-green-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-green-900">Contracted Labor</h4>
|
||||
<p className="text-sm text-green-700">{metrics.contractedRatio.toFixed(1)}% of total spend</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-700">Total Spend</span>
|
||||
<span className="font-bold text-green-900">${metrics.contractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-700">Avg Rate</span>
|
||||
<span className="font-bold text-green-900">${metrics.avgContractedRate.toFixed(2)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-700">Reliability</span>
|
||||
<span className="font-bold text-green-900">92%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-green-700">Fill Rate</span>
|
||||
<span className="font-bold text-green-900">96%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-red-50 rounded-xl border-2 border-red-200">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-red-500 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-red-900">Non-Contracted Labor</h4>
|
||||
<p className="text-sm text-red-700">{(100 - metrics.contractedRatio).toFixed(1)}% of total spend</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-red-700">Total Spend</span>
|
||||
<span className="font-bold text-red-900">${metrics.nonContractedSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-red-700">Avg Rate</span>
|
||||
<span className="font-bold text-red-900">${metrics.avgNonContractedRate.toFixed(2)}/hr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-red-700">Reliability</span>
|
||||
<span className="font-bold text-red-900">71%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-red-700">Fill Rate</span>
|
||||
<span className="font-bold text-red-900">78%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
TrendingUp, DollarSign, Calendar, Target,
|
||||
ArrowRight, CheckCircle, Sparkles, BarChart3,
|
||||
Clock, Users, Zap, ArrowUpRight
|
||||
} from "lucide-react";
|
||||
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Legend } from "recharts";
|
||||
|
||||
export default function PredictiveSavingsModel({ metrics, projections, assignments, rates, userRole }) {
|
||||
// Generate forecast data
|
||||
const forecastData = [
|
||||
{ month: "Jan", current: 45000, optimized: 38000, savings: 7000 },
|
||||
{ month: "Feb", current: 48000, optimized: 40000, savings: 8000 },
|
||||
{ month: "Mar", current: 52000, optimized: 42000, savings: 10000 },
|
||||
{ month: "Apr", current: 50000, optimized: 40000, savings: 10000 },
|
||||
{ month: "May", current: 55000, optimized: 43000, savings: 12000 },
|
||||
{ month: "Jun", current: 58000, optimized: 45000, savings: 13000 },
|
||||
{ month: "Jul", current: 62000, optimized: 47000, savings: 15000 },
|
||||
{ month: "Aug", current: 60000, optimized: 46000, savings: 14000 },
|
||||
{ month: "Sep", current: 55000, optimized: 43000, savings: 12000 },
|
||||
{ month: "Oct", current: 58000, optimized: 45000, savings: 13000 },
|
||||
{ month: "Nov", current: 65000, optimized: 50000, savings: 15000 },
|
||||
{ month: "Dec", current: 70000, optimized: 53000, savings: 17000 },
|
||||
];
|
||||
|
||||
const savingsStrategies = [
|
||||
{
|
||||
id: 1,
|
||||
strategy: "Shift to Higher-Performing Vendors",
|
||||
impact: "High",
|
||||
savingsPercent: 12,
|
||||
timeToImplement: "2-4 weeks",
|
||||
confidence: 92,
|
||||
description: "Consolidate spend with top-tier vendors who offer better rates and reliability",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
strategy: "Contracted Pricing Negotiation",
|
||||
impact: "High",
|
||||
savingsPercent: 15,
|
||||
timeToImplement: "4-6 weeks",
|
||||
confidence: 88,
|
||||
description: "Lock in volume discounts with preferred supplier agreements",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
strategy: "Internal Workforce Pool",
|
||||
impact: "Medium",
|
||||
savingsPercent: 8,
|
||||
timeToImplement: "6-8 weeks",
|
||||
confidence: 85,
|
||||
description: "Build internal pool for recurring needs, reducing agency fees",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
strategy: "Skill-Matched Talent",
|
||||
impact: "Medium",
|
||||
savingsPercent: 6,
|
||||
timeToImplement: "2-3 weeks",
|
||||
confidence: 90,
|
||||
description: "Match worker skills to job requirements, reducing overtime and rework",
|
||||
},
|
||||
];
|
||||
|
||||
const scenarioData = [
|
||||
{ scenario: "Conservative", savings: projections.year * 0.7 },
|
||||
{ scenario: "Moderate", savings: projections.year },
|
||||
{ scenario: "Aggressive", savings: projections.year * 1.4 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Predictive Header */}
|
||||
<Card className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white border-0">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">AI-Powered Savings Forecast</h3>
|
||||
<p className="text-purple-200">Predictive analysis based on your workforce data</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
{[
|
||||
{ label: "7-Day Forecast", value: projections.sevenDays },
|
||||
{ label: "30-Day Forecast", value: projections.thirtyDays },
|
||||
{ label: "Quarterly Forecast", value: projections.quarter },
|
||||
{ label: "Annual Forecast", value: projections.year },
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="bg-white/10 rounded-lg p-4">
|
||||
<p className="text-purple-200 text-sm">{item.label}</p>
|
||||
<p className="text-2xl font-bold">${item.value.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Forecast Chart */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
12-Month Savings Projection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={forecastData}>
|
||||
<defs>
|
||||
<linearGradient id="colorCurrent" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#ef4444" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorOptimized" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#22c55e" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
|
||||
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`$${value.toLocaleString()}`, '']}
|
||||
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="current" stroke="#ef4444" fill="url(#colorCurrent)" name="Current Spend" />
|
||||
<Area type="monotone" dataKey="optimized" stroke="#22c55e" fill="url(#colorOptimized)" name="Optimized Spend" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||
Monthly Savings Breakdown
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={forecastData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis dataKey="month" stroke="#64748b" fontSize={12} />
|
||||
<YAxis stroke="#64748b" fontSize={12} tickFormatter={(v) => `$${(v/1000).toFixed(0)}k`} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`$${value.toLocaleString()}`, 'Savings']}
|
||||
contentStyle={{ background: 'white', border: '1px solid #e2e8f0', borderRadius: '8px' }}
|
||||
/>
|
||||
<Bar dataKey="savings" fill="#0A39DF" radius={[4, 4, 0, 0]} name="Savings" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Savings Strategies */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-amber-500" />
|
||||
Recommended Savings Strategies
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{savingsStrategies.map((strategy) => (
|
||||
<div key={strategy.id} className="p-4 bg-slate-50 rounded-xl border border-slate-200 hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-semibold text-slate-900">{strategy.strategy}</h4>
|
||||
<Badge className={strategy.impact === "High" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}>
|
||||
{strategy.impact} Impact
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{strategy.description}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-green-600">+{strategy.savingsPercent}%</p>
|
||||
<p className="text-xs text-slate-500">potential savings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1 text-slate-500">
|
||||
<Clock className="w-4 h-4" />
|
||||
{strategy.timeToImplement}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-slate-500">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
{strategy.confidence}% confidence
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={() => alert(`Implementing: ${strategy.strategy}`)}>
|
||||
Implement
|
||||
<ArrowRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Scenario Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Scenario Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{scenarioData.map((scenario, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-6 rounded-xl border-2 text-center ${
|
||||
idx === 1
|
||||
? "bg-[#0A39DF] text-white border-[#0A39DF]"
|
||||
: "bg-white border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm font-medium mb-2 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
|
||||
{scenario.scenario}
|
||||
</p>
|
||||
<p className={`text-3xl font-bold ${idx === 1 ? "text-white" : "text-slate-900"}`}>
|
||||
${scenario.savings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${idx === 1 ? "text-blue-200" : "text-slate-500"}`}>
|
||||
annual savings
|
||||
</p>
|
||||
{idx === 1 && (
|
||||
<Badge className="mt-3 bg-white/20 text-white border-0">
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DollarSign, TrendingUp, Target, Users, Zap,
|
||||
ArrowUpRight, CheckCircle, AlertTriangle, Shield,
|
||||
Clock, Award, Calendar, Package, Star, BarChart3
|
||||
} from "lucide-react";
|
||||
|
||||
export default function SavingsOverviewCards({ metrics, projections, timeRange, userRole }) {
|
||||
const getProjectedSavings = () => {
|
||||
switch (timeRange) {
|
||||
case "7days": return projections.sevenDays;
|
||||
case "30days": return projections.thirtyDays;
|
||||
case "quarter": return projections.quarter;
|
||||
case "year": return projections.year;
|
||||
default: return projections.thirtyDays;
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeLabel = () => {
|
||||
switch (timeRange) {
|
||||
case "7days": return "7-Day";
|
||||
case "30days": return "30-Day";
|
||||
case "quarter": return "Quarterly";
|
||||
case "year": return "Annual";
|
||||
default: return "30-Day";
|
||||
}
|
||||
};
|
||||
|
||||
// Role-specific card configurations
|
||||
const getRoleCards = () => {
|
||||
switch (userRole) {
|
||||
case "procurement":
|
||||
return [
|
||||
{
|
||||
title: "Vendor Performance Score",
|
||||
value: `${(metrics.avgReliability + 5).toFixed(0)}%`,
|
||||
change: `Top ${metrics.activeVendors} vendors tracked`,
|
||||
trend: "up",
|
||||
icon: Award,
|
||||
color: "blue",
|
||||
description: "Network-wide average",
|
||||
},
|
||||
{
|
||||
title: "Contract Compliance",
|
||||
value: `${metrics.contractedRatio.toFixed(1)}%`,
|
||||
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-compliant`,
|
||||
trend: metrics.contractedRatio > 70 ? "up" : "down",
|
||||
icon: Shield,
|
||||
color: metrics.contractedRatio > 70 ? "green" : "red",
|
||||
description: "Spend under contract",
|
||||
},
|
||||
{
|
||||
title: "Rate Optimization",
|
||||
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}`,
|
||||
change: "per hour savings potential",
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
color: "emerald",
|
||||
description: "Contract vs. spot rates",
|
||||
},
|
||||
{
|
||||
title: "SLA Adherence",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: CheckCircle,
|
||||
color: metrics.fillRate > 90 ? "green" : "amber",
|
||||
description: "Vendor delivery rate",
|
||||
},
|
||||
{
|
||||
title: "Network Savings",
|
||||
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: `${getTimeLabel()} projection`,
|
||||
trend: "up",
|
||||
icon: TrendingUp,
|
||||
color: "purple",
|
||||
description: "From vendor optimization",
|
||||
},
|
||||
];
|
||||
|
||||
case "operator":
|
||||
return [
|
||||
{
|
||||
title: "Enterprise Fill Rate",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${metrics.completedOrders} orders fulfilled`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: Target,
|
||||
color: metrics.fillRate > 90 ? "green" : "amber",
|
||||
description: "Cross-sector average",
|
||||
},
|
||||
{
|
||||
title: "Labor Efficiency",
|
||||
value: `${metrics.avgReliability.toFixed(0)}%`,
|
||||
change: `${metrics.noShowRate.toFixed(1)}% absence rate`,
|
||||
trend: metrics.avgReliability > 85 ? "up" : "down",
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
description: "Workforce productivity",
|
||||
},
|
||||
{
|
||||
title: "Cost per Order",
|
||||
value: `$${(metrics.totalSpend / Math.max(metrics.completedOrders, 1)).toFixed(0)}`,
|
||||
change: "average fulfillment cost",
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
color: "purple",
|
||||
description: "Operational efficiency",
|
||||
},
|
||||
{
|
||||
title: "Sector Coverage",
|
||||
value: `${metrics.activeVendors}`,
|
||||
change: "active vendor partners",
|
||||
trend: "up",
|
||||
icon: Package,
|
||||
color: "indigo",
|
||||
description: "Available resources",
|
||||
},
|
||||
{
|
||||
title: "Operational Savings",
|
||||
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: `${getTimeLabel()} potential`,
|
||||
trend: "up",
|
||||
icon: TrendingUp,
|
||||
color: "emerald",
|
||||
description: "From efficiency gains",
|
||||
},
|
||||
];
|
||||
|
||||
case "sector":
|
||||
return [
|
||||
{
|
||||
title: "Location Fill Rate",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${100 - metrics.fillRate > 0 ? (100 - metrics.fillRate).toFixed(1) + '% gaps' : 'No gaps'}`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: Target,
|
||||
color: metrics.fillRate > 90 ? "green" : "red",
|
||||
description: "Position coverage",
|
||||
},
|
||||
{
|
||||
title: "Staff Reliability",
|
||||
value: `${metrics.avgReliability.toFixed(0)}%`,
|
||||
change: `${metrics.noShowRate.toFixed(1)}% no-shows`,
|
||||
trend: metrics.avgReliability > 85 ? "up" : "down",
|
||||
icon: Users,
|
||||
color: metrics.avgReliability > 85 ? "blue" : "amber",
|
||||
description: "At your location",
|
||||
},
|
||||
{
|
||||
title: "Weekly Hours",
|
||||
value: `${Math.floor(metrics.totalWorkforce * 32)}`,
|
||||
change: "scheduled this period",
|
||||
trend: "up",
|
||||
icon: Clock,
|
||||
color: "purple",
|
||||
description: "Labor hours planned",
|
||||
},
|
||||
{
|
||||
title: "Local Spend",
|
||||
value: `$${metrics.totalSpend.toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: "labor investment",
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
color: "slate",
|
||||
description: "Your location budget",
|
||||
},
|
||||
];
|
||||
|
||||
case "client":
|
||||
return [
|
||||
{
|
||||
title: "Event Coverage",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${metrics.completedOrders} events staffed`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: Calendar,
|
||||
color: metrics.fillRate > 90 ? "green" : "red",
|
||||
description: "Position fill rate",
|
||||
},
|
||||
{
|
||||
title: "Staff Quality",
|
||||
value: `${metrics.avgReliability.toFixed(0)}%`,
|
||||
change: "reliability score",
|
||||
trend: metrics.avgReliability > 85 ? "up" : "down",
|
||||
icon: Star,
|
||||
color: metrics.avgReliability > 85 ? "amber" : "orange",
|
||||
description: "Assigned workforce",
|
||||
},
|
||||
{
|
||||
title: "Cost Savings",
|
||||
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: `${metrics.potentialSavingsPercent.toFixed(0)}% vs. spot rates`,
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
color: "emerald",
|
||||
description: `${getTimeLabel()} savings`,
|
||||
},
|
||||
{
|
||||
title: "On-Time Rate",
|
||||
value: `${(100 - metrics.noShowRate).toFixed(1)}%`,
|
||||
change: "staff attendance",
|
||||
trend: metrics.noShowRate < 5 ? "up" : "down",
|
||||
icon: CheckCircle,
|
||||
color: metrics.noShowRate < 5 ? "green" : "amber",
|
||||
description: "Punctuality score",
|
||||
},
|
||||
];
|
||||
|
||||
case "vendor":
|
||||
return [
|
||||
{
|
||||
title: "Your Fill Rate",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${metrics.completedOrders} orders completed`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: Target,
|
||||
color: metrics.fillRate > 90 ? "green" : "amber",
|
||||
description: "Order fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Workforce Reliability",
|
||||
value: `${metrics.avgReliability.toFixed(0)}%`,
|
||||
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
|
||||
trend: metrics.avgReliability > 85 ? "up" : "down",
|
||||
icon: Users,
|
||||
color: metrics.avgReliability > 85 ? "blue" : "orange",
|
||||
description: "Your team score",
|
||||
},
|
||||
{
|
||||
title: "Competitive Edge",
|
||||
value: `$${(metrics.avgNonContractedRate - metrics.avgContractedRate).toFixed(2)}/hr`,
|
||||
change: "savings vs. gig rates",
|
||||
trend: "up",
|
||||
icon: Zap,
|
||||
color: "amber",
|
||||
description: "Your value proposition",
|
||||
},
|
||||
{
|
||||
title: "Revenue Potential",
|
||||
value: `$${(metrics.totalSpend * 1.2).toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: "if 100% fill rate",
|
||||
trend: "up",
|
||||
icon: TrendingUp,
|
||||
color: "purple",
|
||||
description: "Growth opportunity",
|
||||
},
|
||||
{
|
||||
title: "Active Workforce",
|
||||
value: metrics.totalWorkforce.toString(),
|
||||
change: "ready to deploy",
|
||||
trend: "up",
|
||||
icon: Shield,
|
||||
color: "indigo",
|
||||
description: "Available staff",
|
||||
},
|
||||
];
|
||||
|
||||
default: // admin
|
||||
return [
|
||||
{
|
||||
title: `${getTimeLabel()} Potential Savings`,
|
||||
value: `$${getProjectedSavings().toLocaleString(undefined, { maximumFractionDigits: 0 })}`,
|
||||
change: `${metrics.potentialSavingsPercent.toFixed(1)}% opportunity`,
|
||||
trend: "up",
|
||||
icon: DollarSign,
|
||||
color: "emerald",
|
||||
description: "From contract conversion",
|
||||
},
|
||||
{
|
||||
title: "Contract Coverage",
|
||||
value: `${metrics.contractedRatio.toFixed(1)}%`,
|
||||
change: `${(100 - metrics.contractedRatio).toFixed(1)}% non-contracted`,
|
||||
trend: metrics.contractedRatio > 70 ? "up" : "down",
|
||||
icon: Target,
|
||||
color: metrics.contractedRatio > 70 ? "blue" : "amber",
|
||||
description: "Labor under contract",
|
||||
},
|
||||
{
|
||||
title: "Platform Reliability",
|
||||
value: `${metrics.avgReliability.toFixed(0)}%`,
|
||||
change: `${metrics.noShowRate.toFixed(1)}% no-show rate`,
|
||||
trend: metrics.avgReliability > 85 ? "up" : "down",
|
||||
icon: Users,
|
||||
color: metrics.avgReliability > 85 ? "purple" : "orange",
|
||||
description: "Workforce average",
|
||||
},
|
||||
{
|
||||
title: "Fill Rate",
|
||||
value: `${metrics.fillRate.toFixed(1)}%`,
|
||||
change: `${metrics.completedOrders} orders completed`,
|
||||
trend: metrics.fillRate > 90 ? "up" : "down",
|
||||
icon: CheckCircle,
|
||||
color: metrics.fillRate > 90 ? "green" : "red",
|
||||
description: "Order fulfillment",
|
||||
},
|
||||
{
|
||||
title: "Network Size",
|
||||
value: `${metrics.activeVendors} / ${metrics.totalWorkforce}`,
|
||||
change: "vendors / workforce",
|
||||
trend: "up",
|
||||
icon: BarChart3,
|
||||
color: "indigo",
|
||||
description: "Platform capacity",
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
const cards = getRoleCards();
|
||||
|
||||
const colorClasses = {
|
||||
emerald: { bg: "bg-emerald-50", icon: "bg-emerald-500", text: "text-emerald-700", badge: "bg-emerald-100 text-emerald-700" },
|
||||
blue: { bg: "bg-blue-50", icon: "bg-blue-500", text: "text-blue-700", badge: "bg-blue-100 text-blue-700" },
|
||||
purple: { bg: "bg-purple-50", icon: "bg-purple-500", text: "text-purple-700", badge: "bg-purple-100 text-purple-700" },
|
||||
green: { bg: "bg-green-50", icon: "bg-green-500", text: "text-green-700", badge: "bg-green-100 text-green-700" },
|
||||
amber: { bg: "bg-amber-50", icon: "bg-amber-500", text: "text-amber-700", badge: "bg-amber-100 text-amber-700" },
|
||||
orange: { bg: "bg-orange-50", icon: "bg-orange-500", text: "text-orange-700", badge: "bg-orange-100 text-orange-700" },
|
||||
red: { bg: "bg-red-50", icon: "bg-red-500", text: "text-red-700", badge: "bg-red-100 text-red-700" },
|
||||
indigo: { bg: "bg-indigo-50", icon: "bg-indigo-500", text: "text-indigo-700", badge: "bg-indigo-100 text-indigo-700" },
|
||||
slate: { bg: "bg-slate-50", icon: "bg-slate-500", text: "text-slate-700", badge: "bg-slate-100 text-slate-700" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{cards.map((card, index) => {
|
||||
const colors = colorClasses[card.color];
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<Card key={index} className={`${colors.bg} border-0 shadow-sm hover:shadow-md transition-all`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`w-12 h-12 ${colors.icon} rounded-xl flex items-center justify-center`}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<Badge className={`${colors.badge} border-0 text-xs font-medium`}>
|
||||
{card.trend === "up" ? (
|
||||
<ArrowUpRight className="w-3 h-3 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
{card.change}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className={`text-xs ${colors.text} uppercase tracking-wider font-semibold mb-1`}>
|
||||
{card.title}
|
||||
</p>
|
||||
<p className={`text-3xl font-bold ${colors.text}`}>{card.value}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Package, Star, TrendingUp, TrendingDown, DollarSign,
|
||||
Users, CheckCircle, AlertTriangle, Award, Shield, Zap
|
||||
} from "lucide-react";
|
||||
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis, Cell } from "recharts";
|
||||
|
||||
export default function VendorPerformanceMatrix({ vendors, assignments, rates, metrics, userRole }) {
|
||||
// Generate vendor performance data
|
||||
const vendorPerformance = vendors.slice(0, 8).map((vendor, idx) => ({
|
||||
id: vendor.id,
|
||||
name: vendor.legal_name || vendor.doing_business_as || `Vendor ${idx + 1}`,
|
||||
region: vendor.region || "Bay Area",
|
||||
avgRate: metrics.avgContractedRate - (Math.random() * 10 - 5),
|
||||
reliability: 80 + Math.random() * 18,
|
||||
fillRate: 85 + Math.random() * 14,
|
||||
noShowRate: Math.random() * 5,
|
||||
onTimeRate: 88 + Math.random() * 11,
|
||||
workforce: vendor.workforce_count || Math.floor(50 + Math.random() * 150),
|
||||
spend: Math.floor(10000 + Math.random() * 50000),
|
||||
score: Math.floor(70 + Math.random() * 28),
|
||||
tier: idx < 2 ? "Preferred" : idx < 5 ? "Approved" : "Standard",
|
||||
savingsPotential: Math.floor(1000 + Math.random() * 5000),
|
||||
}));
|
||||
|
||||
// Scatter plot data
|
||||
const scatterData = vendorPerformance.map(v => ({
|
||||
x: v.avgRate,
|
||||
y: v.reliability,
|
||||
z: v.spend / 1000,
|
||||
name: v.name,
|
||||
tier: v.tier,
|
||||
}));
|
||||
|
||||
const tierColors = {
|
||||
Preferred: "#22c55e",
|
||||
Approved: "#0A39DF",
|
||||
Standard: "#f59e0b",
|
||||
};
|
||||
|
||||
const tierBadgeColors = {
|
||||
Preferred: "bg-green-100 text-green-700",
|
||||
Approved: "bg-blue-100 text-blue-700",
|
||||
Standard: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
const slaMetrics = [
|
||||
{ metric: "Response Time", target: "< 2 hours", achieved: "1.5 hours", status: "met" },
|
||||
{ metric: "Fill Rate", target: "> 95%", achieved: `${metrics.fillRate.toFixed(1)}%`, status: metrics.fillRate > 95 ? "met" : "at-risk" },
|
||||
{ metric: "No-Show Rate", target: "< 3%", achieved: `${metrics.noShowRate.toFixed(1)}%`, status: metrics.noShowRate < 3 ? "met" : "at-risk" },
|
||||
{ metric: "On-Time Arrival", target: "> 98%", achieved: "97.2%", status: "at-risk" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Vendor Scorecard Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center">
|
||||
<Star className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-green-600 font-medium">Preferred Vendors</p>
|
||||
<p className="text-2xl font-bold text-green-900">{vendorPerformance.filter(v => v.tier === "Preferred").length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-blue-600 font-medium">Approved Vendors</p>
|
||||
<p className="text-2xl font-bold text-blue-900">{vendorPerformance.filter(v => v.tier === "Approved").length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-amber-50 border-amber-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-amber-600 font-medium">Standard Vendors</p>
|
||||
<p className="text-2xl font-bold text-amber-900">{vendorPerformance.filter(v => v.tier === "Standard").length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 border-purple-200">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-purple-500 rounded-xl flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600 font-medium">Total Savings Potential</p>
|
||||
<p className="text-2xl font-bold text-purple-900">
|
||||
${vendorPerformance.reduce((sum, v) => sum + v.savingsPotential, 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rate vs Reliability Scatter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-blue-500" />
|
||||
Vendor Performance Matrix (Rate vs. Reliability)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="x"
|
||||
name="Rate"
|
||||
unit="$/hr"
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
label={{ value: 'Hourly Rate ($)', position: 'bottom', offset: 0 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
name="Reliability"
|
||||
unit="%"
|
||||
stroke="#64748b"
|
||||
fontSize={12}
|
||||
domain={[70, 100]}
|
||||
label={{ value: 'Reliability (%)', angle: -90, position: 'left' }}
|
||||
/>
|
||||
<ZAxis type="number" dataKey="z" range={[100, 500]} />
|
||||
<Tooltip
|
||||
cursor={{ strokeDasharray: '3 3' }}
|
||||
content={({ payload }) => {
|
||||
if (payload && payload[0]) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-3 border border-slate-200 rounded-lg shadow-lg">
|
||||
<p className="font-bold text-slate-900">{data.name}</p>
|
||||
<p className="text-sm text-slate-600">Rate: ${data.x.toFixed(2)}/hr</p>
|
||||
<p className="text-sm text-slate-600">Reliability: {data.y.toFixed(1)}%</p>
|
||||
<Badge className={tierBadgeColors[data.tier]}>{data.tier}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Scatter data={scatterData}>
|
||||
{scatterData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={tierColors[entry.tier]} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-center gap-6 mt-4">
|
||||
{Object.entries(tierColors).map(([tier, color]) => (
|
||||
<div key={tier} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm text-slate-600">{tier}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vendor Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50">
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Tier</TableHead>
|
||||
<TableHead className="text-right">Avg Rate</TableHead>
|
||||
<TableHead className="text-right">Reliability</TableHead>
|
||||
<TableHead className="text-right">Fill Rate</TableHead>
|
||||
<TableHead className="text-right">No-Show</TableHead>
|
||||
<TableHead className="text-right">Score</TableHead>
|
||||
<TableHead className="text-right">Savings Potential</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vendorPerformance.map((vendor) => (
|
||||
<TableRow key={vendor.id} className="hover:bg-slate-50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-[#0A39DF] rounded-lg flex items-center justify-center">
|
||||
<Package className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{vendor.name}</p>
|
||||
<p className="text-xs text-slate-500">{vendor.region}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={tierBadgeColors[vendor.tier]}>{vendor.tier}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">${vendor.avgRate.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Progress value={vendor.reliability} className="w-12 h-2" />
|
||||
<span className="text-sm">{vendor.reliability.toFixed(0)}%</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{vendor.fillRate.toFixed(0)}%</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className={vendor.noShowRate < 3 ? "text-green-600" : "text-red-600"}>
|
||||
{vendor.noShowRate.toFixed(1)}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge className={vendor.score >= 90 ? "bg-green-100 text-green-700" : vendor.score >= 75 ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}>
|
||||
{vendor.score}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold text-green-600">
|
||||
${vendor.savingsPotential.toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SLA Tracking */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-amber-500" />
|
||||
Service Level Agreement (SLA) Tracking
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{slaMetrics.map((sla, idx) => (
|
||||
<div key={idx} className={`p-4 rounded-xl border-2 ${sla.status === "met" ? "bg-green-50 border-green-200" : "bg-amber-50 border-amber-200"}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-slate-700">{sla.metric}</span>
|
||||
{sla.status === "met" ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Target</span>
|
||||
<span className="font-medium">{sla.target}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Achieved</span>
|
||||
<span className={`font-bold ${sla.status === "met" ? "text-green-600" : "text-amber-600"}`}>
|
||||
{sla.achieved}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`mt-2 ${sla.status === "met" ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{sla.status === "met" ? "SLA Met" : "At Risk"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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;
|
||||
@@ -1,314 +0,0 @@
|
||||
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 <AlertTriangle className="w-5 h-5 text-red-600" />;
|
||||
case 'medium': return <AlertTriangle className="w-5 h-5 text-amber-600" />;
|
||||
case 'low': return <Clock className="w-5 h-5 text-blue-600" />;
|
||||
default: return <AlertTriangle className="w-5 h-5 text-slate-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConflictIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'staff_overlap': return <Users className="w-4 h-4" />;
|
||||
case 'venue_overlap': return <MapPin className="w-4 h-4" />;
|
||||
case 'time_buffer': return <Clock className="w-4 h-4" />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{conflicts.map((conflict, idx) => (
|
||||
<Alert key={idx} className={`${getSeverityColor(conflict.severity)} border-2 relative`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getSeverityIcon(conflict.severity)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getConflictIcon(conflict.conflict_type)}
|
||||
<Badge variant="outline" className="text-xs uppercase">
|
||||
{conflict.conflict_type.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge className={`text-xs ${
|
||||
conflict.severity === 'critical' || conflict.severity === 'high'
|
||||
? 'bg-red-600 text-white'
|
||||
: conflict.severity === 'medium'
|
||||
? 'bg-amber-600 text-white'
|
||||
: 'bg-blue-600 text-white'
|
||||
}`}>
|
||||
{conflict.severity.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<AlertDescription className="font-medium text-slate-900 text-sm">
|
||||
{conflict.description}
|
||||
</AlertDescription>
|
||||
{conflict.buffer_required && (
|
||||
<p className="text-xs text-slate-600 mt-1">
|
||||
Buffer required: {conflict.buffer_required}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 flex-shrink-0"
|
||||
onClick={() => onDismiss(idx)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
detectTimeOverlap,
|
||||
detectDateOverlap,
|
||||
detectStaffConflicts,
|
||||
detectVenueConflicts,
|
||||
detectBufferViolations,
|
||||
detectAllConflicts,
|
||||
ConflictAlert,
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertTriangle, Clock, Calendar } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export default function DoubleBookingOverrideDialog({
|
||||
open,
|
||||
onClose,
|
||||
conflict,
|
||||
workerName,
|
||||
onConfirm
|
||||
}) {
|
||||
if (!conflict) return null;
|
||||
|
||||
const { existingEvent, existingShift, gapMinutes, canOverride } = conflict;
|
||||
|
||||
const existingShiftTime = existingShift?.roles?.[0] || existingShift || {};
|
||||
const existingStart = existingShiftTime.start_time || '00:00';
|
||||
const existingEnd = existingShiftTime.end_time || '23:59';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-orange-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
{canOverride ? 'Double Shift Assignment' : 'Assignment Blocked'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{canOverride
|
||||
? `${workerName} is finishing another shift within ${gapMinutes} minutes of this assignment.`
|
||||
: `${workerName} cannot be assigned due to a scheduling conflict.`
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-orange-200 bg-orange-50">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
<AlertDescription className="text-sm text-orange-900">
|
||||
{conflict.reason}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Existing Assignment
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Event:</span>
|
||||
<span className="font-medium text-slate-900">{existingEvent?.event_name || 'Unnamed Event'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Location:</span>
|
||||
<span className="font-medium text-slate-900">{existingEvent?.hub || existingEvent?.event_location || '-'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Date:</span>
|
||||
<span className="font-medium text-slate-900">
|
||||
{existingEvent?.date ? format(new Date(existingEvent.date), 'MMM d, yyyy') : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Time:</span>
|
||||
<div className="flex items-center gap-1.5 font-medium text-slate-900">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>{existingStart} - {existingEnd}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canOverride && (
|
||||
<Alert className="border-blue-200 bg-blue-50">
|
||||
<AlertDescription className="text-sm text-blue-900">
|
||||
As a vendor, you can override this restriction and assign {workerName} to a double shift.
|
||||
Please ensure the worker has adequate rest and complies with labor regulations.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{canOverride ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel Assignment
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Override & Assign Double Shift
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { parseISO, isSameDay } from "date-fns";
|
||||
|
||||
/**
|
||||
* Parses time string (HH:MM or HH:MM AM/PM) into minutes since midnight
|
||||
*/
|
||||
const parseTimeToMinutes = (timeStr) => {
|
||||
if (!timeStr) return 0;
|
||||
|
||||
try {
|
||||
const cleanTime = timeStr.trim().toUpperCase();
|
||||
let hours, minutes;
|
||||
|
||||
if (cleanTime.includes('AM') || cleanTime.includes('PM')) {
|
||||
const isPM = cleanTime.includes('PM');
|
||||
const timePart = cleanTime.replace(/AM|PM/g, '').trim();
|
||||
[hours, minutes] = timePart.split(':').map(Number);
|
||||
|
||||
if (isPM && hours !== 12) hours += 12;
|
||||
if (!isPM && hours === 12) hours = 0;
|
||||
} else {
|
||||
[hours, minutes] = cleanTime.split(':').map(Number);
|
||||
}
|
||||
|
||||
return (hours * 60) + (minutes || 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a worker is already assigned to an event on a given date
|
||||
*/
|
||||
export const getWorkerAssignments = (workerId, events, targetDate) => {
|
||||
const targetDateObj = typeof targetDate === 'string' ? parseISO(targetDate) : targetDate;
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.assigned_staff || event.status === 'Canceled') return false;
|
||||
|
||||
// Check if worker is assigned to this event
|
||||
const isAssigned = event.assigned_staff.some(staff =>
|
||||
staff.staff_id === workerId || staff.id === workerId
|
||||
);
|
||||
|
||||
if (!isAssigned) return false;
|
||||
|
||||
// Check if event is on the same date
|
||||
const eventDate = typeof event.date === 'string' ? parseISO(event.date) : new Date(event.date);
|
||||
return isSameDay(eventDate, targetDateObj);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if two shifts overlap or violate spacing rules
|
||||
* Returns: { allowed: boolean, needsOverride: boolean, reason: string, gapMinutes: number }
|
||||
*/
|
||||
export const checkShiftConflict = (shift1, shift2) => {
|
||||
if (!shift1 || !shift2) {
|
||||
return { allowed: true, needsOverride: false, reason: '', gapMinutes: 0 };
|
||||
}
|
||||
|
||||
// Get time ranges from shifts
|
||||
const shift1Start = shift1.roles?.[0]?.start_time || shift1.start_time || '00:00';
|
||||
const shift1End = shift1.roles?.[0]?.end_time || shift1.end_time || '23:59';
|
||||
const shift2Start = shift2.roles?.[0]?.start_time || shift2.start_time || '00:00';
|
||||
const shift2End = shift2.roles?.[0]?.end_time || shift2.end_time || '23:59';
|
||||
|
||||
const s1Start = parseTimeToMinutes(shift1Start);
|
||||
const s1End = parseTimeToMinutes(shift1End);
|
||||
const s2Start = parseTimeToMinutes(shift2Start);
|
||||
const s2End = parseTimeToMinutes(shift2End);
|
||||
|
||||
// Check for direct overlap
|
||||
const overlaps = (s1Start < s2End && s1End > s2Start);
|
||||
|
||||
if (overlaps) {
|
||||
return {
|
||||
allowed: false,
|
||||
needsOverride: false,
|
||||
reason: 'Shifts overlap. This worker is unavailable due to an overlapping shift.',
|
||||
gapMinutes: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate gap between shifts
|
||||
let gapMinutes;
|
||||
if (s1End <= s2Start) {
|
||||
// Shift 1 ends before Shift 2 starts
|
||||
gapMinutes = s2Start - s1End;
|
||||
} else if (s2End <= s1Start) {
|
||||
// Shift 2 ends before Shift 1 starts
|
||||
gapMinutes = s1Start - s2End;
|
||||
} else {
|
||||
gapMinutes = 0;
|
||||
}
|
||||
|
||||
// If gap is more than 1 hour (60 minutes), it's allowed without override
|
||||
if (gapMinutes > 60) {
|
||||
return {
|
||||
allowed: true,
|
||||
needsOverride: false,
|
||||
reason: '',
|
||||
gapMinutes
|
||||
};
|
||||
}
|
||||
|
||||
// If gap is 1 hour or less, vendor can override (double shift scenario)
|
||||
return {
|
||||
allowed: false,
|
||||
needsOverride: true,
|
||||
reason: `This employee is finishing another shift within ${gapMinutes} minutes of this assignment. Vendor can override to assign a double shift.`,
|
||||
gapMinutes
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a worker can be assigned to a shift
|
||||
* Returns: { valid: boolean, conflict: object | null, message: string }
|
||||
*/
|
||||
export const validateWorkerAssignment = (workerId, targetEvent, targetShift, allEvents, userRole) => {
|
||||
// Get all assignments for this worker on the target date
|
||||
const existingAssignments = getWorkerAssignments(workerId, allEvents, targetEvent.date);
|
||||
|
||||
// If no existing assignments, allow
|
||||
if (existingAssignments.length === 0) {
|
||||
return { valid: true, conflict: null, message: '' };
|
||||
}
|
||||
|
||||
// Check conflicts with each existing assignment
|
||||
for (const existingEvent of existingAssignments) {
|
||||
// Skip if it's the same event (editing existing assignment)
|
||||
if (existingEvent.id === targetEvent.id) continue;
|
||||
|
||||
// Check each shift in the existing event
|
||||
for (const existingShift of (existingEvent.shifts || [])) {
|
||||
const conflict = checkShiftConflict(existingShift, targetShift);
|
||||
|
||||
if (!conflict.allowed) {
|
||||
if (conflict.needsOverride) {
|
||||
// Vendor can override for double shifts within 1 hour
|
||||
if (userRole === 'vendor') {
|
||||
return {
|
||||
valid: false,
|
||||
conflict: {
|
||||
...conflict,
|
||||
existingEvent,
|
||||
existingShift,
|
||||
canOverride: true
|
||||
},
|
||||
message: conflict.reason
|
||||
};
|
||||
} else {
|
||||
// Non-vendors cannot override
|
||||
return {
|
||||
valid: false,
|
||||
conflict: {
|
||||
...conflict,
|
||||
existingEvent,
|
||||
existingShift,
|
||||
canOverride: false
|
||||
},
|
||||
message: 'This worker is unavailable due to an overlapping shift or extended gap. Assigning this employee is not permitted.'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Hard conflict - no override allowed
|
||||
return {
|
||||
valid: false,
|
||||
conflict: {
|
||||
...conflict,
|
||||
existingEvent,
|
||||
existingShift,
|
||||
canOverride: false
|
||||
},
|
||||
message: 'This worker is unavailable due to an overlapping shift or extended gap. Assigning this employee is not permitted.'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, conflict: null, message: '' };
|
||||
};
|
||||
@@ -1,343 +0,0 @@
|
||||
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";
|
||||
import { validateWorkerAssignment } from "./DoubleBookingValidator";
|
||||
import DoubleBookingOverrideDialog from "./DoubleBookingOverrideDialog";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { base44 } from "@/api/base44Client";
|
||||
|
||||
/**
|
||||
* 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 [overrideDialog, setOverrideDialog] = useState({ open: false, conflict: null, staffMember: null, eventId: null });
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: user } = useQuery({
|
||||
queryKey: ['current-user-scheduler'],
|
||||
queryFn: () => base44.auth.me(),
|
||||
});
|
||||
|
||||
const { data: allEvents = [] } = useQuery({
|
||||
queryKey: ['all-events-conflict-check'],
|
||||
queryFn: () => base44.entities.Event.list(),
|
||||
initialData: events,
|
||||
});
|
||||
|
||||
const userRole = user?.user_role || user?.role || 'admin';
|
||||
|
||||
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);
|
||||
const targetEvent = localEvents.find(e => e.id === eventId);
|
||||
|
||||
if (!staffMember || !targetEvent) return;
|
||||
|
||||
// Validate double booking
|
||||
const targetShift = targetEvent.shifts?.[0] || {};
|
||||
const validation = validateWorkerAssignment(
|
||||
staffMember.id,
|
||||
targetEvent,
|
||||
targetShift,
|
||||
allEvents,
|
||||
userRole
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
if (validation.conflict?.canOverride) {
|
||||
// Show override dialog for vendors
|
||||
setOverrideDialog({
|
||||
open: true,
|
||||
conflict: validation.conflict,
|
||||
staffMember,
|
||||
eventId
|
||||
});
|
||||
} else {
|
||||
// Hard block
|
||||
toast({
|
||||
title: "❌ Assignment Blocked",
|
||||
description: validation.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverrideConfirm = () => {
|
||||
const { staffMember, eventId } = overrideDialog;
|
||||
|
||||
// Proceed with assignment
|
||||
setLocalStaff(localStaff.filter(s => s.id !== staffMember.id));
|
||||
|
||||
setLocalEvents(localEvents.map(event => {
|
||||
if (event.id === eventId) {
|
||||
return {
|
||||
...event,
|
||||
assigned_staff: [...(event.assigned_staff || []), { staff_id: staffMember.id, staff_name: staffMember.employee_name }]
|
||||
};
|
||||
}
|
||||
return event;
|
||||
}));
|
||||
|
||||
onAssign(eventId, staffMember);
|
||||
setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null });
|
||||
|
||||
toast({
|
||||
title: "✅ Double Shift Assigned",
|
||||
description: `${staffMember.employee_name} has been assigned with vendor override`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Unassigned Staff Pool */}
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Available Staff</CardTitle>
|
||||
<p className="text-sm text-slate-500">{localStaff.length} unassigned</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Droppable droppableId="unassigned">
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`min-h-[400px] rounded-lg p-3 transition-colors ${
|
||||
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-blue-300' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{localStaff.map((s, index) => (
|
||||
<Draggable key={s.id} draggableId={s.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`bg-white rounded-lg p-3 mb-2 border border-slate-200 shadow-sm ${
|
||||
snapshot.isDragging ? 'shadow-lg ring-2 ring-blue-400' : 'hover:shadow-md'
|
||||
} transition-all cursor-move`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-700 font-bold">
|
||||
{s.employee_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-sm truncate">{s.employee_name}</p>
|
||||
<p className="text-xs text-slate-500">{s.position}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Star className="w-3 h-3 mr-1 text-amber-500" />
|
||||
{s.rating || 4.5}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{s.reliability_score || 95}% reliable
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
{localStaff.length === 0 && (
|
||||
<p className="text-center text-slate-400 mt-8">All staff assigned</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Events Schedule */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{localEvents.map(event => (
|
||||
<Card key={event.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">{event.event_name}</CardTitle>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{format(new Date(event.date), 'MMM d, yyyy')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{event.hub || event.event_location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={
|
||||
(event.assigned_staff?.length || 0) >= (event.requested || 0)
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}>
|
||||
{event.assigned_staff?.length || 0}/{event.requested || 0} filled
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Droppable droppableId={`event-${event.id}`}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`min-h-[120px] rounded-lg p-3 transition-colors ${
|
||||
snapshot.isDraggingOver ? 'bg-green-50 border-2 border-green-300' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{event.assigned_staff?.map((s, index) => (
|
||||
<Draggable key={s.staff_id} draggableId={s.staff_id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={`bg-white rounded-lg p-2 border border-slate-200 ${
|
||||
snapshot.isDragging ? 'shadow-lg ring-2 ring-green-400' : ''
|
||||
} cursor-move`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback className="bg-green-100 text-green-700 text-xs">
|
||||
{s.staff_name?.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-xs truncate">{s.staff_name}</p>
|
||||
<p className="text-xs text-slate-500">{s.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
</div>
|
||||
{provided.placeholder}
|
||||
{(!event.assigned_staff || event.assigned_staff.length === 0) && (
|
||||
<p className="text-center text-slate-400 text-sm py-8">
|
||||
Drag staff here to assign
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
<DoubleBookingOverrideDialog
|
||||
open={overrideDialog.open}
|
||||
onClose={() => setOverrideDialog({ open: false, conflict: null, staffMember: null, eventId: null })}
|
||||
conflict={overrideDialog.conflict}
|
||||
workerName={overrideDialog.staffMember?.employee_name || ''}
|
||||
onConfirm={handleOverrideConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* Overtime & Double Time Calculator
|
||||
* Calculates OT/DT exposure based on state regulations
|
||||
*/
|
||||
|
||||
// State-specific OT/DT rules
|
||||
const STATE_RULES = {
|
||||
CA: {
|
||||
dailyOT: 8, // OT after 8 hours/day
|
||||
dailyDT: 12, // DT after 12 hours/day
|
||||
weeklyOT: 40, // OT after 40 hours/week
|
||||
seventhDayDT: true, // 7th consecutive day = DT
|
||||
otRate: 1.5,
|
||||
dtRate: 2.0,
|
||||
},
|
||||
DEFAULT: {
|
||||
dailyOT: null, // No daily OT in most states
|
||||
dailyDT: null,
|
||||
weeklyOT: 40,
|
||||
seventhDayDT: false,
|
||||
otRate: 1.5,
|
||||
dtRate: 2.0,
|
||||
}
|
||||
};
|
||||
|
||||
export function getStateRules(state) {
|
||||
return STATE_RULES[state] || STATE_RULES.DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate OT status for a worker considering a shift
|
||||
* @param {Object} worker - Worker with current hours
|
||||
* @param {Object} shift - Shift to assign
|
||||
* @param {Array} allEvents - All events to check existing assignments
|
||||
* @returns {Object} OT analysis
|
||||
*/
|
||||
export function calculateOTStatus(worker, shift, allEvents = []) {
|
||||
const state = shift.state || worker.state || "DEFAULT";
|
||||
const rules = getStateRules(state);
|
||||
|
||||
// Get shift duration
|
||||
const shiftHours = calculateShiftHours(shift);
|
||||
|
||||
// Calculate current hours from existing assignments
|
||||
const currentHours = calculateWorkerCurrentHours(worker, allEvents, shift.date);
|
||||
|
||||
// Project new hours
|
||||
const projectedDayHours = currentHours.currentDayHours + shiftHours;
|
||||
const projectedWeekHours = currentHours.currentWeekHours + shiftHours;
|
||||
|
||||
// Calculate OT/DT
|
||||
let otHours = 0;
|
||||
let dtHours = 0;
|
||||
let status = "GREEN";
|
||||
let summary = "No OT or DT triggered";
|
||||
let costImpact = 0;
|
||||
|
||||
// Daily OT/DT (CA-specific)
|
||||
if (rules.dailyOT && projectedDayHours > rules.dailyOT) {
|
||||
if (rules.dailyDT && projectedDayHours > rules.dailyDT) {
|
||||
// Some hours are DT
|
||||
dtHours = projectedDayHours - rules.dailyDT;
|
||||
otHours = rules.dailyDT - rules.dailyOT;
|
||||
status = "RED";
|
||||
summary = `Triggers ${otHours.toFixed(1)}h OT + ${dtHours.toFixed(1)}h DT (${state})`;
|
||||
} else {
|
||||
// Only OT, no DT
|
||||
otHours = projectedDayHours - rules.dailyOT;
|
||||
status = projectedDayHours >= rules.dailyDT - 1 ? "AMBER" : "AMBER";
|
||||
summary = `Triggers ${otHours.toFixed(1)}h OT (${state})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly OT
|
||||
if (rules.weeklyOT && projectedWeekHours > rules.weeklyOT && !otHours) {
|
||||
otHours = projectedWeekHours - rules.weeklyOT;
|
||||
status = "AMBER";
|
||||
summary = `Triggers ${otHours.toFixed(1)}h weekly OT`;
|
||||
}
|
||||
|
||||
// Near thresholds (warning zone)
|
||||
if (status === "GREEN") {
|
||||
if (rules.dailyOT && projectedDayHours >= rules.dailyOT - 1) {
|
||||
status = "AMBER";
|
||||
summary = `Near daily OT threshold (${projectedDayHours.toFixed(1)}h)`;
|
||||
} else if (rules.weeklyOT && projectedWeekHours >= rules.weeklyOT - 4) {
|
||||
status = "AMBER";
|
||||
summary = `Approaching weekly OT (${projectedWeekHours.toFixed(1)}h)`;
|
||||
} else {
|
||||
summary = `Safe · No OT (${projectedDayHours.toFixed(1)}h day, ${projectedWeekHours.toFixed(1)}h week)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cost impact
|
||||
const baseRate = worker.hourly_rate || shift.rate_per_hour || 20;
|
||||
const baseCost = shiftHours * baseRate;
|
||||
const otCost = otHours * baseRate * rules.otRate;
|
||||
const dtCost = dtHours * baseRate * rules.dtRate;
|
||||
costImpact = otCost + dtCost;
|
||||
|
||||
return {
|
||||
status,
|
||||
summary,
|
||||
currentDayHours: currentHours.currentDayHours,
|
||||
currentWeekHours: currentHours.currentWeekHours,
|
||||
projectedDayHours,
|
||||
projectedWeekHours,
|
||||
otHours,
|
||||
dtHours,
|
||||
baseCost,
|
||||
costImpact,
|
||||
totalCost: baseCost + costImpact,
|
||||
rulePattern: `${state}_${rules.dailyOT ? 'DAILY' : 'WEEKLY'}_OT`,
|
||||
canAssign: true, // Always allow but warn
|
||||
requiresApproval: status === "RED",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate shift duration in hours
|
||||
*/
|
||||
function calculateShiftHours(shift) {
|
||||
if (shift.hours) return shift.hours;
|
||||
|
||||
// Try to parse from start/end times
|
||||
if (shift.start_time && shift.end_time) {
|
||||
const [startH, startM] = shift.start_time.split(':').map(Number);
|
||||
const [endH, endM] = shift.end_time.split(':').map(Number);
|
||||
const startMins = startH * 60 + startM;
|
||||
const endMins = endH * 60 + endM;
|
||||
const duration = (endMins - startMins) / 60;
|
||||
return duration > 0 ? duration : duration + 24; // Handle overnight
|
||||
}
|
||||
|
||||
return 8; // Default 8-hour shift
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate worker's current hours for the day and week
|
||||
*/
|
||||
function calculateWorkerCurrentHours(worker, allEvents, shiftDate) {
|
||||
let currentDayHours = 0;
|
||||
let currentWeekHours = 0;
|
||||
|
||||
if (!allEvents || !shiftDate) {
|
||||
return {
|
||||
currentDayHours: worker.current_day_hours || 0,
|
||||
currentWeekHours: worker.current_week_hours || 0,
|
||||
};
|
||||
}
|
||||
|
||||
const shiftDateObj = new Date(shiftDate);
|
||||
const shiftDay = shiftDateObj.getDay();
|
||||
|
||||
// Get start of week (Sunday)
|
||||
const weekStart = new Date(shiftDateObj);
|
||||
weekStart.setDate(shiftDateObj.getDate() - shiftDay);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
// Count hours from existing assignments
|
||||
allEvents.forEach(event => {
|
||||
if (event.status === "Canceled" || event.status === "Completed") return;
|
||||
|
||||
const isAssigned = event.assigned_staff?.some(s => s.staff_id === worker.id);
|
||||
if (!isAssigned) return;
|
||||
|
||||
const eventDate = new Date(event.date);
|
||||
|
||||
// Same day hours
|
||||
if (eventDate.toDateString() === shiftDateObj.toDateString()) {
|
||||
(event.shifts || []).forEach(shift => {
|
||||
(shift.roles || []).forEach(role => {
|
||||
if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
|
||||
currentDayHours += role.hours || 8;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Same week hours
|
||||
if (eventDate >= weekStart && eventDate <= shiftDateObj) {
|
||||
(event.shifts || []).forEach(shift => {
|
||||
(shift.roles || []).forEach(role => {
|
||||
if (event.assigned_staff?.some(s => s.staff_id === worker.id && s.role === role.role)) {
|
||||
currentWeekHours += role.hours || 8;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { currentDayHours, currentWeekHours };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OT badge component props
|
||||
*/
|
||||
export function getOTBadgeProps(status) {
|
||||
switch (status) {
|
||||
case "GREEN":
|
||||
return {
|
||||
className: "bg-emerald-500 text-white",
|
||||
label: "Safe · No OT"
|
||||
};
|
||||
case "AMBER":
|
||||
return {
|
||||
className: "bg-amber-500 text-white",
|
||||
label: "Near OT"
|
||||
};
|
||||
case "RED":
|
||||
return {
|
||||
className: "bg-red-500 text-white",
|
||||
label: "OT/DT Risk"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
className: "bg-slate-500 text-white",
|
||||
label: "Unknown"
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user