new temporal folder to test

This commit is contained in:
José Salazar
2025-12-04 18:02:28 -05:00
parent cf18fdb16b
commit 48d86436e3
252 changed files with 120330 additions and 0 deletions

View File

@@ -11,3 +11,8 @@ generate:
packageJsonDir: ../../internal-api-harness
react: true
angular: false
- outputDir: ../../frontend-web-free/src/dataconnect-generated
package: "@dataconnect/generated"
packageJsonDir: ../../frontend-web-free
react: true
angular: false

26
frontend-web-free/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env

View File

@@ -0,0 +1,20 @@
# Base44 App
This app was created automatically by Base44.
It's a Vite+React app that communicates with the Base44 API.
## Running the app
```bash
npm install
npm run dev
```
## Building the app
```bash
npm run build
```
For more information and support, please contact Base44 support at app@base44.com.

View File

@@ -0,0 +1,21 @@
{
"$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"
}

View File

@@ -0,0 +1,38 @@
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 },
],
},
},
]

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="https://base44.com/logo_v2.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Base44 APP</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx"
},
"include": ["src/**/*.js", "src/**/*.jsx"]
}

9985
frontend-web-free/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
{
"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",
"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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

View File

@@ -0,0 +1,14 @@
import './App.css'
import Pages from "@/pages/index.jsx"
import { Toaster } from "@/components/ui/toaster"
function App() {
return (
<>
<Pages />
<Toaster />
</>
)
}
export default App

View File

@@ -0,0 +1,11 @@
/*import { createClient } from '@base44/sdk';
// import { getAccessToken } from '@base44/sdk/utils/auth-utils';
// Create a client with authentication required
export const base44 = createClient({
appId: "68fc6cf01386035c266e7a5d",
requiresAuth: true // Ensure authentication is required for all operations
});*/
import { krowSDK } from "@/api/krowSDK";
export const base44 = krowSDK;

View File

@@ -0,0 +1,22 @@
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;

View File

@@ -0,0 +1,81 @@
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;

View File

@@ -0,0 +1,26 @@
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;

View File

@@ -0,0 +1,675 @@
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`);
};
// helper para normalizar siempre a array (list + filter devolverán arrays)
const normalizeResultToArray = (res) => {
// if it is an array, perfect
if (Array.isArray(res)) return res;
if (!res || typeof res !== 'object') return [];
const data = res.data;
if (data && typeof data === 'object') {
const keys = Object.keys(data);
if (keys.length === 0) return [];
const firstValue = data[keys[0]];
if (Array.isArray(firstValue)) return firstValue;
if (firstValue == null) return [];
// if it is a single object, return it as an array
return [firstValue];
}
return [];
};
// --- 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 (...args) => {
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);
/*let variables;
const maybeVars = params[0];
if (maybeVars && typeof maybeVars === 'object' && !Array.isArray(maybeVars)) {
variables = maybeVars;
}
const res = await fn(dataConnect, variables);
return normalizeResultToArray(res);
*/
let sort;
let limit;
let baseVariables; // por si algún list usa variables (como Team)
if (args.length === 1) {
const [a0] = args;
if (typeof a0 === "string") {
// list('-created_date')
sort = a0;
} else if (a0 && typeof a0 === "object" && !Array.isArray(a0)) {
// list({ ...vars }) -> reservado para queries que acepten variables
baseVariables = a0;
}
} else if (args.length === 2) {
const [a0, a1] = args;
if (typeof a0 === "string" && typeof a1 === "number") {
// list('-created_date', 50)
sort = a0;
limit = a1;
} else if (a0 && typeof a0 === "object" && !Array.isArray(a0)) {
// list({ ...vars }, '-created_date')
baseVariables = a0;
if (typeof a1 === "string") sort = a1;
}
} else if (args.length >= 3) {
const [a0, a1, a2] = args;
if (a0 && typeof a0 === "object" && !Array.isArray(a0)) {
// list({ ...vars }, '-created_date', 50)
baseVariables = a0;
if (typeof a1 === "string") sort = a1;
if (typeof a2 === "number") limit = a2;
}
}
// COMMENT FIX: variables que realmente se mandan a DataConnect
let variables = baseVariables;
// COMMENT FIX: caso especial para Team, que SÍ tiene orderBy/limit en el query
if (entityName === "Team") {
variables = variables || {};
if (sort) {
const desc = sort.startsWith("-");
variables.orderByCreatedDate = desc ? "DESC" : "ASC";
}
if (typeof limit === "number") {
variables.limit = limit;
}
}
const res = await fn(dataConnect, variables);
let items = normalizeResultToArray(res);
// COMMENT FIX: para entidades que NO tienen orderBy/limit en el query,
// aplicamos sort/limit en el front como fallback.
if (entityName !== "Team" && sort) {
const desc = sort.startsWith("-");
const field = desc ? sort.slice(1) : sort; // '-created_date' -> 'created_date'
items = items.slice().sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const da = new Date(av);
const db = new Date(bv);
if (!isNaN(da) && !isNaN(db)) {
return desc ? db - da : da - db;
}
if (av < bv) return desc ? 1 : -1;
if (av > bv) return desc ? -1 : 1;
return 0;
});
}
if (entityName !== "Team" && typeof limit === "number") {
items = items.slice(0, limit);
}
return items;
},
}),
// 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);*/
if (!params || typeof params !== 'object') {
throw new Error(
`${entityName}.create expects an object with the fields to insert`
);
}
let payload;
if (params.data && typeof params.data === 'object') {
// caso nuevo: create({ data: { ... } })
payload = params.data;
} else {
// caso legacy: create({ ...fields })
payload = params;
}
return fn(dataConnect, payload);
},
}),
//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 (...args) => {
const fn = dcSdk[ops.filter];
if (typeof fn !== 'function') {
throw new Error(
`Data Connect operation "${ops.filter}" not found for entity "${entityName}".`
);
}
// FIX: soportar firma vieja: filter(filters, sort, limit)
const [params, sort, limit] = args;
// FIX: soportar firma vieja: filter(filters, sort, limit)
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);
//fix
const resList = await listFn(dataConnect);
return normalizeResultToArray(resList);
//fix
}
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);
//fix
const resList = await listFn(dataConnect);
return normalizeResultToArray(resList);
//fix
}
throw new Error(`${entityName}.filter received no valid filters and no list operation`);
}
//return fn(dataConnect, variables);
//fix
const res = await fn(dataConnect, variables);
// FIX: normalizar a array (activityLogs[], messages[], etc.)
let items = normalizeResultToArray(res);
// FIX: soportar sort tipo '-created_date' o 'created_date'
if (sort) {
const desc = sort.startsWith('-');
const field = desc ? sort.slice(1) : sort;
items = items.slice().sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
// Intentar tratarlos como fecha si parecen fechas
const da = new Date(av);
const db = new Date(bv);
if (!isNaN(da) && !isNaN(db)) {
return desc ? db - da : da - db;
}
// Fallback: comparación normal
if (av < bv) return desc ? 1 : -1;
if (av > bv) return desc ? -1 : 1;
return 0;
});
}
// FIX: soportar limit (ej: 50)
if (typeof limit === 'number') {
items = items.slice(0, limit);
}
return items;
//fix
},
}),
};
});
// --- Main SDK Export ---
export const krowSDK = {
auth: authModule,
integrations: {
Core: coreIntegrationsModule,
},
entities: entitiesModule,
};

View File

@@ -0,0 +1,25 @@
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;
}

View File

@@ -0,0 +1,25 @@
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;
}

View File

@@ -0,0 +1,273 @@
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>
);
}

View File

@@ -0,0 +1,292 @@
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>
);
}

View File

@@ -0,0 +1,256 @@
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>
);
}

View File

@@ -0,0 +1,321 @@
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>
</>
);
}

View File

@@ -0,0 +1,128 @@
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>
);
}

View File

@@ -0,0 +1,72 @@
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>
);
}

View File

@@ -0,0 +1,46 @@
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>
);
}

View File

@@ -0,0 +1,396 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Settings,
GripVertical,
X,
Plus,
Eye,
EyeOff,
Info,
Save,
RotateCcw,
Sparkles
} from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
export default function DashboardCustomizer({
user,
availableWidgets = [],
currentLayout = [],
onLayoutChange,
dashboardType = "default" // admin, client, vendor, operator, etc
}) {
const [isOpen, setIsOpen] = useState(false);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [visibleWidgets, setVisibleWidgets] = useState([]);
const [hiddenWidgets, setHiddenWidgets] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
// Initialize widgets from user's saved layout or defaults
useEffect(() => {
const layoutKey = `dashboard_layout_${dashboardType}`;
const savedLayout = user?.[layoutKey];
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
const savedVisible = savedLayout.widgets
.map(id => availableWidgets.find(w => w.id === id))
.filter(Boolean);
setVisibleWidgets(savedVisible);
const savedHidden = savedLayout.hidden_widgets || [];
const hiddenWidgetsList = availableWidgets.filter(w =>
savedHidden.includes(w.id)
);
setHiddenWidgets(hiddenWidgetsList);
} else {
// Default: all widgets visible in provided order
setVisibleWidgets(availableWidgets);
setHiddenWidgets([]);
}
}, [user, availableWidgets, isOpen, dashboardType]);
// Save layout mutation
const saveLayoutMutation = useMutation({
mutationFn: async (layoutData) => {
const layoutKey = `dashboard_layout_${dashboardType}`;
await base44.auth.updateMe({
[layoutKey]: layoutData
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['current-user'] });
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
toast({
title: "✅ Layout Saved",
description: "Your dashboard layout has been updated",
});
setHasChanges(false);
if (onLayoutChange) {
onLayoutChange(visibleWidgets);
}
setTimeout(() => {
setIsOpen(false);
}, 500);
},
onError: () => {
toast({
title: "❌ Save Failed",
description: "Could not save your layout. Please try again.",
variant: "destructive",
});
}
});
const handleDragEnd = (result) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId === "visible" && destination.droppableId === "visible") {
const items = Array.from(visibleWidgets);
const [reorderedItem] = items.splice(source.index, 1);
items.splice(destination.index, 0, reorderedItem);
setVisibleWidgets(items);
setHasChanges(true);
}
};
const handleHideWidget = (widget) => {
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
setHiddenWidgets([...hiddenWidgets, widget]);
setHasChanges(true);
};
const handleShowWidget = (widget) => {
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
setVisibleWidgets([...visibleWidgets, widget]);
setHasChanges(true);
};
const handleSave = () => {
const layoutData = {
widgets: visibleWidgets.map(w => w.id),
hidden_widgets: hiddenWidgets.map(w => w.id),
layout_version: "2.0"
};
saveLayoutMutation.mutate(layoutData);
};
const handleReset = () => {
setVisibleWidgets(availableWidgets);
setHiddenWidgets([]);
setHasChanges(true);
};
const handleOpenCustomizer = () => {
setIsOpen(true);
setShowHowItWorks(true);
setHasChanges(false);
};
const handleClose = () => {
if (hasChanges) {
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
setIsOpen(false);
setHasChanges(false);
}
} else {
setIsOpen(false);
}
};
return (
<>
{/* Customize Button */}
<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">
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
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}
className={`bg-white border-2 rounded-lg p-4 transition-all ${
snapshot.isDragging
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
: '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>
</>
);
}

View File

@@ -0,0 +1,316 @@
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>);
}

View File

@@ -0,0 +1,43 @@
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>
);
}

View File

@@ -0,0 +1,224 @@
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>
</>
);
}

View File

@@ -0,0 +1,632 @@
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>
);
}

View File

@@ -0,0 +1,244 @@
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>
</>
);
}

View File

@@ -0,0 +1,837 @@
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

View File

@@ -0,0 +1,134 @@
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>
);
}

View File

@@ -0,0 +1,136 @@
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>
);
}

View File

@@ -0,0 +1,210 @@
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>
);
}

View File

@@ -0,0 +1,249 @@
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>
);
}

View File

@@ -0,0 +1,162 @@
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}
/>
</>
);
}

View File

@@ -0,0 +1,426 @@
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>
);
}

View File

@@ -0,0 +1,323 @@
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>
);
}

View File

@@ -0,0 +1,320 @@
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);
}

View File

@@ -0,0 +1,611 @@
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>
);
}

View File

@@ -0,0 +1,687 @@
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>
);
}

View File

@@ -0,0 +1,29 @@
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>
);
}

View File

@@ -0,0 +1,340 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Award, Star, MapPin, Users, TrendingUp, AlertTriangle, Zap, CheckCircle2, Send } from "lucide-react";
export default function VendorRoutingPanel({
user,
selectedVendors = [],
onVendorChange,
isRapid = false
}) {
const [showVendorSelector, setShowVendorSelector] = useState(false);
const [selectionMode, setSelectionMode] = useState('single'); // 'single' | 'multi'
// Fetch preferred vendor
const { data: preferredVendor } = useQuery({
queryKey: ['preferred-vendor-routing', user?.preferred_vendor_id],
queryFn: async () => {
if (!user?.preferred_vendor_id) return null;
const vendors = await base44.entities.Vendor.list();
return vendors.find(v => v.id === user.preferred_vendor_id);
},
enabled: !!user?.preferred_vendor_id,
});
// Fetch all approved vendors
const { data: allVendors } = useQuery({
queryKey: ['all-vendors-routing'],
queryFn: () => base44.entities.Vendor.filter({
approval_status: 'approved',
is_active: true
}),
initialData: [],
});
// Auto-select preferred vendor on mount if none selected
React.useEffect(() => {
if (preferredVendor && selectedVendors.length === 0) {
onVendorChange([preferredVendor]);
}
}, [preferredVendor]);
const handleVendorSelect = (vendor) => {
if (selectionMode === 'single') {
onVendorChange([vendor]);
setShowVendorSelector(false);
} else {
// Multi-select mode
const isSelected = selectedVendors.some(v => v.id === vendor.id);
if (isSelected) {
onVendorChange(selectedVendors.filter(v => v.id !== vendor.id));
} else {
onVendorChange([...selectedVendors, vendor]);
}
}
};
const handleMultiVendorDone = () => {
if (selectedVendors.length === 0) {
alert("Please select at least one vendor");
return;
}
setShowVendorSelector(false);
};
const routingMode = selectedVendors.length > 1 ? 'multi' : 'single';
return (
<>
<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>
</>
);
}

View File

@@ -0,0 +1,175 @@
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
}

View File

@@ -0,0 +1,200 @@
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>
);
}

View File

@@ -0,0 +1,444 @@
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>
);
}

View File

@@ -0,0 +1,447 @@
import React, { useState } from "react";
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 { useToast } from "@/components/ui/use-toast";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Printer, Flag, CheckCircle, MoreVertical, FileText, Edit, CreditCard
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format, parseISO } from "date-fns";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
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 InvoiceDetailView({ invoice, userRole, onClose }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [showDisputeDialog, setShowDisputeDialog] = useState(false);
const [disputeReason, setDisputeReason] = useState("");
const [disputeDetails, setDisputeDetails] = 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",
});
if (onClose) 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 handleMarkPaid = async () => {
const user = await base44.auth.me();
updateInvoiceMutation.mutate({
id: invoice.id,
data: {
status: "Paid",
paid_date: new Date().toISOString().split('T')[0],
payment_method: "Credit Card",
payment_reference: `PAY-${Date.now()}`,
}
});
};
const handleEditInvoice = () => {
navigate(createPageUrl(`InvoiceEditor?id=${invoice.id}`));
};
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(),
}
});
setShowDisputeDialog(false);
};
const handlePrint = () => {
window.print();
};
const toggleItemSelection = (roleIndex, staffIndex) => {
const itemId = { role_index: roleIndex, staff_index: staffIndex };
setSelectedItems(prev => {
const exists = prev.some(item => item.role_index === roleIndex && item.staff_index === staffIndex);
if (exists) {
return prev.filter(item => !(item.role_index === roleIndex && item.staff_index === staffIndex));
}
return [...prev, itemId];
});
};
if (!invoice) return null;
const isClient = userRole === "client";
const isVendor = userRole === "vendor";
const isAdmin = userRole === "admin";
const canEdit = (isVendor || isAdmin) && ["Draft", "Pending Review", "Disputed"].includes(invoice.status);
const canApprove = isClient && invoice.status === "Pending Review";
const canPay = isClient && invoice.status === "Approved";
const canDispute = isClient && ["Pending Review", "Approved"].includes(invoice.status);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<FileText className="w-8 h-8 text-[#0A39DF]" />
<div>
<h1 className="text-3xl font-bold text-slate-900">{invoice.invoice_number}</h1>
<Badge className={`${statusColors[invoice.status]} text-white px-3 py-1 mt-2`}>
{invoice.status}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handlePrint}>
<Printer className="w-4 h-4 mr-2" />
Print
</Button>
{canEdit && (
<Button variant="outline" className="text-blue-600 border-blue-200 hover:bg-blue-50" onClick={handleEditInvoice}>
<Edit className="w-4 h-4 mr-2" />
Edit Invoice
</Button>
)}
{canDispute && (
<Button variant="outline" className="text-red-600 border-red-200 hover:bg-red-50" onClick={() => setShowDisputeDialog(true)}>
<Flag className="w-4 h-4 mr-2" />
Dispute Invoice
</Button>
)}
{canApprove && (
<Button className="bg-green-600 hover:bg-green-700" onClick={handleApprove}>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Invoice
</Button>
)}
{canPay && (
<Button className="bg-[#0A39DF] hover:bg-[#0831b8]" onClick={handleMarkPaid}>
<CreditCard className="w-4 h-4 mr-2" />
Mark as Paid
</Button>
)}
</div>
</div>
{/* Event Info */}
<div className="flex flex-wrap gap-6 text-sm text-slate-600">
<div>
<span className="font-semibold">Event Name:</span> {invoice.event_name}
</div>
<div>
<span className="font-semibold">PO#:</span> {invoice.po_reference || "N/A"}
</div>
<div>
<span className="font-semibold">Date:</span> {invoice.event_date ? format(parseISO(invoice.event_date), 'M.d.yyyy') : '—'}
</div>
<div>
<span className="font-semibold">Due date:</span> <span className="text-red-600 font-bold">{format(parseISO(invoice.due_date), 'M.d.yyyy')}</span>
</div>
</div>
</div>
{/* KROW Logo */}
<div className="mb-6">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW"
className="h-12"
/>
</div>
{/* From and To */}
<div className="grid grid-cols-2 gap-6 mb-8">
<div className="bg-blue-50 rounded-xl p-6 border-2 border-blue-200">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-[#0A39DF] rounded-full flex items-center justify-center">
<span className="text-white font-bold text-sm">F</span>
</div>
<h3 className="font-bold text-lg text-slate-900">From:</h3>
</div>
<div className="space-y-2">
<p className="font-bold text-lg text-slate-900">{invoice.from_company?.name || invoice.vendor_name || "Vendor Name"}</p>
{(invoice.from_company?.address || invoice.vendor_address) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Address</p>
<p>{invoice.from_company?.address || invoice.vendor_address}</p>
</div>
)}
{(invoice.from_company?.email || invoice.vendor_email) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Email</p>
<p>{invoice.from_company?.email || invoice.vendor_email}</p>
</div>
)}
{(invoice.from_company?.phone || invoice.vendor_phone) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Phone</p>
<p>{invoice.from_company?.phone || invoice.vendor_phone}</p>
</div>
)}
{(invoice.from_company?.contact || invoice.vendor_contact) && (
<div className="text-sm text-slate-700">
<p className="font-semibold text-slate-500 text-xs mb-1">Point of Contact</p>
<p>{invoice.from_company?.contact || invoice.vendor_contact}</p>
</div>
)}
</div>
</div>
<div className="bg-green-50 rounded-xl p-6 border-2 border-green-200">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-green-600 rounded-full flex items-center justify-center">
<span className="text-white font-bold text-sm">T</span>
</div>
<h3 className="font-bold text-lg text-slate-900">To:</h3>
</div>
<div className="space-y-1 text-sm">
<p className="font-bold text-slate-900">{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>
<div className="grid grid-cols-2 gap-4 mt-3 pt-3 border-t border-green-200">
<div>
<p className="text-xs text-slate-500">Main Kitchen</p>
<p className="font-semibold text-slate-900">{invoice.to_company?.manager || invoice.manager_name}</p>
</div>
<div>
<p className="text-xs text-slate-500">Manager Name</p>
<p className="font-semibold text-slate-900">{invoice.to_company?.phone}</p>
</div>
</div>
<p className="text-slate-600">{invoice.to_company?.vendor_id || "Vendor #"}</p>
</div>
</div>
</div>
{/* Staff Charges Table */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-6">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">#</th>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">Date</th>
<th className="px-4 py-3 text-left text-xs font-bold text-slate-700">Position</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Worked Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Reg Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">OT Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">DT Hours</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Reg Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">OT Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">DT Value</th>
<th className="px-4 py-3 text-right text-xs font-bold text-slate-700">Total</th>
<th className="px-4 py-3 text-center text-xs font-bold text-slate-700">Actions</th>
</tr>
</thead>
<tbody>
{invoice.roles?.map((roleGroup, roleIdx) => (
<React.Fragment key={roleIdx}>
{roleGroup.staff_entries?.map((entry, entryIdx) => (
<tr key={`${roleIdx}-${entryIdx}`} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-4 py-3 text-sm text-slate-900">{roleIdx + 1}</td>
<td className="px-4 py-3 text-sm text-slate-600">{entry.date ? format(parseISO(entry.date), 'M/d/yyyy') : '—'}</td>
<td className="px-4 py-3 text-sm font-medium text-slate-900">{entry.position}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.worked_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.regular_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.ot_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">{entry.dt_hours?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.regular_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.ot_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right text-slate-700">${entry.dt_value?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-sm text-right font-bold text-slate-900">${entry.total?.toFixed(2) || '0.00'}</td>
<td className="px-4 py-3 text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>View Details</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Flag Entry</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
<tr className="bg-slate-100 font-semibold">
<td colSpan="10" className="px-4 py-2 text-sm text-slate-700">Total</td>
<td className="px-4 py-2 text-sm text-right font-bold">${roleGroup.role_subtotal?.toFixed(2)}</td>
<td></td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
{/* Other Charges */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-6">
<div className="bg-slate-50 px-6 py-3 border-b border-slate-200">
<h3 className="font-bold text-slate-900">Other charges</h3>
</div>
<table className="w-full">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700">#</th>
<th className="px-6 py-3 text-left text-xs font-bold text-slate-700">Charge</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">QTY</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">Rate</th>
<th className="px-6 py-3 text-right text-xs font-bold text-slate-700">Amount</th>
</tr>
</thead>
<tbody>
{(!invoice.other_charges || invoice.other_charges === 0) ? (
<tr>
<td colSpan="5" className="px-6 py-8 text-center text-sm text-slate-500">
No additional charges
</td>
</tr>
) : (
<tr className="border-b border-slate-100">
<td className="px-6 py-3 text-sm">1</td>
<td className="px-6 py-3 text-sm">Additional Charges</td>
<td className="px-6 py-3 text-sm text-right">1</td>
<td className="px-6 py-3 text-sm text-right">${invoice.other_charges?.toFixed(2)}</td>
<td className="px-6 py-3 text-sm text-right font-semibold">${invoice.other_charges?.toFixed(2)}</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Totals */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<div className="max-w-md ml-auto space-y-3">
<div className="flex justify-between text-base">
<span className="text-slate-600">Sub-total:</span>
<span className="font-bold text-slate-900">${invoice.subtotal?.toFixed(2)}</span>
</div>
<div className="flex justify-between text-base">
<span className="text-slate-600">Other charges:</span>
<span className="font-bold text-slate-900">${(invoice.other_charges || 0)?.toFixed(2)}</span>
</div>
<div className="border-t-2 border-slate-300 pt-3 flex justify-between text-xl">
<span className="font-bold text-slate-900">Grand total:</span>
<span className="font-bold text-[#0A39DF]">${invoice.amount?.toFixed(2)}</span>
</div>
</div>
</div>
{/* Footer */}
<div className="mt-8 flex items-center justify-between text-sm text-slate-500">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW"
className="h-8"
/>
<span>Page 1</span>
</div>
</div>
{/* Dispute Dialog */}
<Dialog open={showDisputeDialog} onOpenChange={setShowDisputeDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Dispute Invoice</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<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="Calculation Error">Calculation Error</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={5}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDisputeDialog(false)}>
Cancel
</Button>
<Button
onClick={handleDispute}
disabled={!disputeReason}
className="bg-red-600 hover:bg-red-700"
>
Submit Dispute
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,316 @@
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>
);
}

View File

@@ -0,0 +1,108 @@
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>
);
}

View File

@@ -0,0 +1,70 @@
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>
);
}

View File

@@ -0,0 +1,99 @@
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>
);
}

View File

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

View File

@@ -0,0 +1,622 @@
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({ userId: 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({ userId: 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>
);
}

View File

@@ -0,0 +1,141 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, User, FileText, BookOpen, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function CompletionStep({ data, onComplete, onBack, isSubmitting }) {
const { profile, documents, training } = data;
return (
<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>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Upload, FileText, CheckCircle, X } from "lucide-react";
import { base44 } from "@/api/base44Client";
import { useToast } from "@/components/ui/use-toast";
const requiredDocuments = [
{ id: 'id', name: 'Government ID', required: true, description: 'Driver\'s license or passport' },
{ id: 'certification', name: 'Certifications', required: false, description: 'Food handler, TIPS, etc.' },
{ id: 'background_check', name: 'Background Check', required: false, description: 'If available' },
];
export default function DocumentUploadStep({ data, onNext, onBack }) {
const [documents, setDocuments] = useState(data || []);
const [uploading, setUploading] = useState({});
const { toast } = useToast();
const handleFileUpload = async (docType, file) => {
if (!file) return;
setUploading(prev => ({ ...prev, [docType]: true }));
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const newDoc = {
type: docType,
name: file.name,
url: file_url,
uploaded_at: new Date().toISOString(),
};
setDocuments(prev => {
const filtered = prev.filter(d => d.type !== docType);
return [...filtered, newDoc];
});
toast({
title: "✅ Document Uploaded",
description: `${file.name} uploaded successfully`,
});
} catch (error) {
toast({
title: "❌ Upload Failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(prev => ({ ...prev, [docType]: false }));
}
};
const handleRemoveDocument = (docType) => {
setDocuments(prev => prev.filter(d => d.type !== docType));
};
const handleNext = () => {
const hasRequiredDocs = requiredDocuments
.filter(doc => doc.required)
.every(doc => documents.some(d => d.type === doc.id));
if (!hasRequiredDocs) {
toast({
title: "⚠️ Missing Required Documents",
description: "Please upload all required documents before continuing",
variant: "destructive",
});
return;
}
onNext({ type: 'documents', data: documents });
};
const getUploadedDoc = (docType) => documents.find(d => d.type === docType);
return (
<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>
);
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { User, Briefcase, MapPin } from "lucide-react";
export default function ProfileSetupStep({ data, onNext, currentUser }) {
const [profile, setProfile] = useState({
full_name: data.full_name || currentUser?.full_name || "",
email: data.email || currentUser?.email || "",
phone: data.phone || "",
address: data.address || "",
city: data.city || "",
position: data.position || "",
department: data.department || "",
hub_location: data.hub_location || "",
employment_type: data.employment_type || "Full Time",
english_level: data.english_level || "Fluent",
});
const handleSubmit = (e) => {
e.preventDefault();
onNext({ type: 'profile', data: profile });
};
const handleChange = (field, value) => {
setProfile(prev => ({ ...prev, [field]: value }));
};
return (
<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>
);
}

View File

@@ -0,0 +1,173 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { CheckCircle, Circle, Play, BookOpen } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
const trainingModules = [
{
id: 'safety',
title: 'Workplace Safety',
duration: '15 min',
required: true,
description: 'Learn about workplace safety protocols and emergency procedures',
topics: ['Emergency exits', 'Fire safety', 'First aid basics', 'Reporting incidents'],
},
{
id: 'hygiene',
title: 'Food Safety & Hygiene',
duration: '20 min',
required: true,
description: 'Essential food handling and hygiene standards',
topics: ['Handwashing', 'Cross-contamination', 'Temperature control', 'Storage guidelines'],
},
{
id: 'customer_service',
title: 'Customer Service Excellence',
duration: '10 min',
required: true,
description: 'Delivering outstanding service to clients and guests',
topics: ['Communication skills', 'Handling complaints', 'Professional etiquette', 'Teamwork'],
},
{
id: 'compliance',
title: 'Compliance & Policies',
duration: '12 min',
required: true,
description: 'Company policies and legal compliance requirements',
topics: ['Code of conduct', 'Anti-discrimination', 'Data privacy', 'Time tracking'],
},
];
export default function TrainingStep({ data, onNext, onBack }) {
const [training, setTraining] = useState(data || { completed: [], acknowledged: false });
const handleModuleComplete = (moduleId) => {
setTraining(prev => ({
...prev,
completed: prev.completed.includes(moduleId)
? prev.completed.filter(id => id !== moduleId)
: [...prev.completed, moduleId],
}));
};
const handleAcknowledge = (checked) => {
setTraining(prev => ({ ...prev, acknowledged: checked }));
};
const handleNext = () => {
const allRequired = trainingModules
.filter(m => m.required)
.every(m => training.completed.includes(m.id));
if (!allRequired || !training.acknowledged) {
return;
}
onNext({ type: 'training', data: training });
};
const isComplete = (moduleId) => training.completed.includes(moduleId);
const allRequiredComplete = trainingModules
.filter(m => m.required)
.every(m => training.completed.includes(m.id));
return (
<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>
);
}

View File

@@ -0,0 +1,161 @@
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>
);
}

View File

@@ -0,0 +1,362 @@
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>
);
}

View File

@@ -0,0 +1,109 @@
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>
);
}

View File

@@ -0,0 +1,149 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Zap, Clock, AlertTriangle, CheckCircle, XCircle, Package } from "lucide-react";
// Comprehensive color coding system
export const ORDER_STATUSES = {
RAPID: {
color: "bg-red-600 text-white border-0",
dotColor: "bg-red-400",
icon: Zap,
label: "RAPID",
priority: 1,
description: "Must be filled immediately"
},
REQUESTED: {
color: "bg-yellow-500 text-white border-0",
dotColor: "bg-yellow-300",
icon: Clock,
label: "Requested",
priority: 2,
description: "Pending vendor review"
},
PARTIALLY_ASSIGNED: {
color: "bg-orange-500 text-white border-0",
dotColor: "bg-orange-300",
icon: AlertTriangle,
label: "Partially Assigned",
priority: 3,
description: "Missing staff"
},
FULLY_ASSIGNED: {
color: "bg-green-600 text-white border-0",
dotColor: "bg-green-400",
icon: CheckCircle,
label: "Fully Assigned",
priority: 4,
description: "All staff confirmed"
},
AT_RISK: {
color: "bg-purple-600 text-white border-0",
dotColor: "bg-purple-400",
icon: AlertTriangle,
label: "At Risk",
priority: 2,
description: "Workers not confirmed or declined"
},
COMPLETED: {
color: "bg-slate-400 text-white border-0",
dotColor: "bg-slate-300",
icon: CheckCircle,
label: "Completed",
priority: 5,
description: "Invoice and approval pending"
},
PERMANENT: {
color: "bg-purple-700 text-white border-0",
dotColor: "bg-purple-500",
icon: Package,
label: "Permanent",
priority: 3,
description: "Permanent staffing"
},
CANCELED: {
color: "bg-slate-500 text-white border-0",
dotColor: "bg-slate-300",
icon: XCircle,
label: "Canceled",
priority: 6,
description: "Order canceled"
}
};
export function getOrderStatus(order) {
// Check if RAPID
if (order.is_rapid || order.event_name?.includes("RAPID")) {
return ORDER_STATUSES.RAPID;
}
const assignedCount = order.assigned_staff?.length || 0;
const requestedCount = order.requested || 0;
// Check completion status
if (order.status === "Completed") {
return ORDER_STATUSES.COMPLETED;
}
if (order.status === "Canceled") {
return ORDER_STATUSES.CANCELED;
}
// Check if permanent
if (order.contract_type === "Permanent" || order.event_type === "Permanent") {
return ORDER_STATUSES.PERMANENT;
}
// Check assignment status
if (requestedCount > 0) {
if (assignedCount >= requestedCount) {
return ORDER_STATUSES.FULLY_ASSIGNED;
} else if (assignedCount > 0) {
return ORDER_STATUSES.PARTIALLY_ASSIGNED;
} else {
return ORDER_STATUSES.REQUESTED;
}
}
// Default to requested
return ORDER_STATUSES.REQUESTED;
}
export default function OrderStatusBadge({ order, size = "default", showIcon = true, showDot = true, className = "" }) {
const status = getOrderStatus(order);
const Icon = status.icon;
const sizeClasses = {
sm: "px-2 py-0.5 text-[10px]",
default: "px-3 py-1 text-xs",
lg: "px-4 py-1.5 text-sm"
};
return (
<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);
});
}

View File

@@ -0,0 +1,37 @@
// 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";
}

View File

@@ -0,0 +1,332 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Zap, Send, Check, Edit3, MapPin, Clock, Users, AlertCircle, Sparkles } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { motion, AnimatePresence } from "framer-motion";
export default function RapidOrderChat({ onOrderCreated }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const [conversation, setConversation] = useState([]);
const [detectedOrder, setDetectedOrder] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const { data: user } = useQuery({
queryKey: ['current-user-rapid'],
queryFn: () => base44.auth.me(),
});
const { data: businesses } = useQuery({
queryKey: ['user-businesses'],
queryFn: () => base44.entities.Business.filter({ contact_name: user?.full_name }),
enabled: !!user,
initialData: [],
});
const createRapidOrderMutation = useMutation({
mutationFn: (orderData) => base44.entities.Event.create(orderData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: "✅ RAPID Order Created",
description: "Order sent to preferred vendor with priority notification",
});
if (onOrderCreated) onOrderCreated(data);
// Reset
setConversation([]);
setDetectedOrder(null);
setMessage("");
},
});
const analyzeMessage = async (msg) => {
setIsProcessing(true);
// Add user message to conversation
setConversation(prev => [...prev, { role: 'user', content: msg }]);
try {
// Use AI to parse the message
const response = await base44.integrations.Core.InvokeLLM({
prompt: `You are an order assistant. Analyze this message and extract order details:
Message: "${msg}"
Current user: ${user?.full_name}
User's locations: ${businesses.map(b => b.business_name).join(', ')}
Extract:
1. Urgency keywords (ASAP, today, emergency, call out, urgent, rapid, now)
2. Role/position needed (cook, bartender, server, dishwasher, etc.)
3. Number of staff (if mentioned)
4. Time frame (if mentioned)
5. Location (if mentioned, otherwise use first available location)
Return a concise summary.`,
response_json_schema: {
type: "object",
properties: {
is_urgent: { type: "boolean" },
role: { type: "string" },
count: { type: "number" },
location: { type: "string" },
time_mentioned: { type: "boolean" },
start_time: { type: "string" },
end_time: { type: "string" }
}
}
});
const parsed = response;
const primaryLocation = businesses[0]?.business_name || "Primary Location";
const order = {
is_rapid: parsed.is_urgent || true,
role: parsed.role || "Staff Member",
count: parsed.count || 1,
location: parsed.location || primaryLocation,
start_time: parsed.start_time || "ASAP",
end_time: parsed.end_time || "End of shift",
business_name: primaryLocation,
hub: businesses[0]?.hub_building || "Main Hub"
};
setDetectedOrder(order);
// AI response
const aiMessage = `Is this a RAPID ORDER for **${order.count} ${order.role}${order.count > 1 ? 's' : ''}** at **${order.location}**?\n\nTime: ${order.start_time}${order.end_time}`;
setConversation(prev => [...prev, {
role: 'assistant',
content: aiMessage,
showConfirm: true
}]);
} catch (error) {
setConversation(prev => [...prev, {
role: 'assistant',
content: "I couldn't process that. Please provide more details like: role needed, how many, and when."
}]);
} finally {
setIsProcessing(false);
}
};
const handleSendMessage = () => {
if (!message.trim()) return;
analyzeMessage(message);
setMessage("");
};
const handleConfirmOrder = () => {
if (!detectedOrder) return;
const now = new Date();
const orderData = {
event_name: `RAPID: ${detectedOrder.count} ${detectedOrder.role}${detectedOrder.count > 1 ? 's' : ''}`,
is_rapid: true,
status: "Pending",
business_name: detectedOrder.business_name,
hub: detectedOrder.hub,
event_location: detectedOrder.location,
date: now.toISOString().split('T')[0],
requested: detectedOrder.count,
client_name: user?.full_name,
client_email: user?.email,
notes: `RAPID ORDER - ${detectedOrder.start_time} to ${detectedOrder.end_time}`,
shifts: [{
shift_name: "Emergency Shift",
roles: [{
role: detectedOrder.role,
count: detectedOrder.count,
start_time: "ASAP",
end_time: "End of shift"
}]
}]
};
createRapidOrderMutation.mutate(orderData);
};
const handleEditOrder = () => {
setConversation(prev => [...prev, {
role: 'assistant',
content: "Please describe what you'd like to change."
}]);
setDetectedOrder(null);
};
return (
<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>
);
}

View File

@@ -0,0 +1,276 @@
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>
);
}

View File

@@ -0,0 +1,374 @@
import React, { useState, useMemo } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Sparkles, Star, MapPin, Clock, Award, TrendingUp, AlertCircle, CheckCircle, Zap, Users, RefreshCw } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
export default function SmartAssignModal({ isOpen, onClose, event, roleNeeded, countNeeded }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedWorkers, setSelectedWorkers] = useState([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [aiRecommendations, setAiRecommendations] = useState(null);
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-smart-assign'],
queryFn: () => base44.entities.Staff.list(),
});
const { data: allEvents = [] } = useQuery({
queryKey: ['events-conflict-check'],
queryFn: () => base44.entities.Event.list(),
});
// Smart filtering
const eligibleStaff = useMemo(() => {
if (!event || !roleNeeded) return [];
return allStaff.filter(worker => {
// Role match
const hasRole = worker.position === roleNeeded ||
worker.position_2 === roleNeeded ||
worker.profile_type === "Cross-Trained";
// Availability check
const isAvailable = worker.employment_type !== "Medical Leave" &&
worker.action !== "Inactive";
// Conflict check - check if worker is already assigned
const eventDate = new Date(event.date);
const hasConflict = allEvents.some(e => {
if (e.id === event.id) return false;
const eDate = new Date(e.date);
return eDate.toDateString() === eventDate.toDateString() &&
e.assigned_staff?.some(s => s.staff_id === worker.id);
});
return hasRole && isAvailable && !hasConflict;
});
}, [allStaff, event, roleNeeded, allEvents]);
// Run AI analysis
const runSmartAnalysis = async () => {
setIsAnalyzing(true);
try {
const prompt = `You are a workforce optimization AI. Analyze these workers and recommend the best ${countNeeded} for this job.
Event: ${event.event_name}
Location: ${event.event_location || event.hub}
Role Needed: ${roleNeeded}
Quantity: ${countNeeded}
Workers (JSON):
${JSON.stringify(eligibleStaff.map(w => ({
id: w.id,
name: w.employee_name,
rating: w.rating || 0,
reliability_score: w.reliability_score || 0,
total_shifts: w.total_shifts || 0,
no_show_count: w.no_show_count || 0,
position: w.position,
city: w.city,
profile_type: w.profile_type
})), null, 2)}
Rank them by:
1. Skills match (exact role match gets priority)
2. Rating (higher is better)
3. Reliability (lower no-shows, higher reliability score)
4. Experience (more shifts completed)
5. Distance (prefer closer to location)
Return the top ${countNeeded} worker IDs with brief reasoning.`;
const response = await base44.integrations.Core.InvokeLLM({
prompt,
response_json_schema: {
type: "object",
properties: {
recommendations: {
type: "array",
items: {
type: "object",
properties: {
worker_id: { type: "string" },
reason: { type: "string" },
score: { type: "number" }
}
}
}
}
}
});
const recommended = response.recommendations.map(rec => {
const worker = eligibleStaff.find(w => w.id === rec.worker_id);
return worker ? { ...worker, ai_reason: rec.reason, ai_score: rec.score } : null;
}).filter(Boolean);
setAiRecommendations(recommended);
setSelectedWorkers(recommended.slice(0, countNeeded));
toast({
title: "✨ AI Analysis Complete",
description: `Found ${recommended.length} optimal matches`,
});
} catch (error) {
toast({
title: "Analysis Failed",
description: error.message,
variant: "destructive",
});
} finally {
setIsAnalyzing(false);
}
};
const assignMutation = useMutation({
mutationFn: async () => {
const assigned_staff = selectedWorkers.map(w => ({
staff_id: w.id,
staff_name: w.employee_name,
role: roleNeeded
}));
return base44.entities.Event.update(event.id, {
assigned_staff: [...(event.assigned_staff || []), ...assigned_staff],
status: "Confirmed"
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
toast({
title: "✅ Staff Assigned Successfully",
description: `${selectedWorkers.length} workers assigned to ${event.event_name}`,
});
onClose();
},
});
React.useEffect(() => {
if (isOpen && eligibleStaff.length > 0 && !aiRecommendations) {
runSmartAnalysis();
}
}, [isOpen, eligibleStaff.length]);
const toggleWorker = (worker) => {
setSelectedWorkers(prev => {
const exists = prev.find(w => w.id === worker.id);
if (exists) {
return prev.filter(w => w.id !== worker.id);
} else if (prev.length < countNeeded) {
return [...prev, worker];
}
return prev;
});
};
return (
<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>
);
}

View File

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

View File

@@ -0,0 +1,228 @@
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>
);
}

View File

@@ -0,0 +1,192 @@
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>
);
}

View File

@@ -0,0 +1,650 @@
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>
);
}

View File

@@ -0,0 +1,133 @@
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>
);
}

View File

@@ -0,0 +1,226 @@
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>
);
}

View File

@@ -0,0 +1,346 @@
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>
);
}

View File

@@ -0,0 +1,217 @@
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>
);
}

View File

@@ -0,0 +1,202 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, TrendingUp, Users, Star } from "lucide-react";
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
export default function ClientTrendsReport({ events, invoices }) {
const { toast } = useToast();
// Bookings by month
const bookingsByMonth = events.reduce((acc, event) => {
if (!event.date) return acc;
const date = new Date(event.date);
const month = date.toLocaleString('default', { month: 'short' });
acc[month] = (acc[month] || 0) + 1;
return acc;
}, {});
const monthlyBookings = Object.entries(bookingsByMonth).map(([month, count]) => ({
month,
bookings: count,
}));
// Top clients by booking count
const clientBookings = events.reduce((acc, event) => {
const client = event.business_name || 'Unknown';
if (!acc[client]) {
acc[client] = { name: client, bookings: 0, revenue: 0 };
}
acc[client].bookings += 1;
acc[client].revenue += event.total || 0;
return acc;
}, {});
const topClients = Object.values(clientBookings)
.sort((a, b) => b.bookings - a.bookings)
.slice(0, 10);
// Client satisfaction (mock data - would come from feedback)
const avgSatisfaction = 4.6;
const totalClients = new Set(events.map(e => e.business_name).filter(Boolean)).size;
const repeatRate = ((events.filter(e => e.is_recurring).length / events.length) * 100).toFixed(1);
const handleExport = () => {
const csv = [
['Client Trends Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Total Clients', totalClients],
['Average Satisfaction', avgSatisfaction],
['Repeat Booking Rate', `${repeatRate}%`],
[''],
['Top Clients'],
['Client Name', 'Bookings', 'Revenue'],
...topClients.map(c => [c.name, c.bookings, c.revenue.toFixed(2)]),
[''],
['Monthly Bookings'],
['Month', 'Bookings'],
...monthlyBookings.map(m => [m.month, m.bookings]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `client-trends-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Client trends report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Client Satisfaction & Booking Trends</h2>
<p className="text-sm text-slate-500">Track client engagement and satisfaction metrics</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Clients</p>
<p className="text-2xl font-bold text-slate-900">{totalClients}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Satisfaction</p>
<p className="text-2xl font-bold text-slate-900">{avgSatisfaction}/5</p>
<div className="flex gap-0.5 mt-1">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`w-4 h-4 ${i < Math.floor(avgSatisfaction) ? 'fill-amber-400 text-amber-400' : 'text-slate-300'}`} />
))}
</div>
</div>
<div className="w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<Star className="w-6 h-6 text-amber-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Repeat Rate</p>
<p className="text-2xl font-bold text-slate-900">{repeatRate}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</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>
);
}

View File

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

View File

@@ -0,0 +1,238 @@
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Zap, Clock, TrendingUp, CheckCircle } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from "recharts";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
export default function OperationalEfficiencyReport({ events, staff }) {
const { toast } = useToast();
// Automation impact metrics
const totalEvents = events.length;
const autoAssignedEvents = events.filter(e =>
e.assigned_staff && e.assigned_staff.length > 0
).length;
const automationRate = totalEvents > 0 ? ((autoAssignedEvents / totalEvents) * 100).toFixed(1) : 0;
// Fill rate by status
const statusBreakdown = events.reduce((acc, event) => {
const status = event.status || 'Draft';
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
const statusData = Object.entries(statusBreakdown).map(([name, value]) => ({
name,
value,
}));
// Time to fill metrics
const avgTimeToFill = 2.3; // Mock - would calculate from event creation to full assignment
const avgResponseTime = 1.5; // Mock - hours to respond to requests
// Efficiency over time
const efficiencyTrend = [
{ month: 'Jan', automation: 75, fillRate: 88, responseTime: 2.1 },
{ month: 'Feb', automation: 78, fillRate: 90, responseTime: 1.9 },
{ month: 'Mar', automation: 82, fillRate: 92, responseTime: 1.7 },
{ month: 'Apr', automation: 85, fillRate: 94, responseTime: 1.5 },
];
const handleExport = () => {
const csv = [
['Operational Efficiency Report'],
['Generated', new Date().toISOString()],
[''],
['Summary Metrics'],
['Total Events', totalEvents],
['Auto-Assigned Events', autoAssignedEvents],
['Automation Rate', `${automationRate}%`],
['Avg Time to Fill (hours)', avgTimeToFill],
['Avg Response Time (hours)', avgResponseTime],
[''],
['Status Breakdown'],
['Status', 'Count'],
...Object.entries(statusBreakdown).map(([status, count]) => [status, count]),
[''],
['Efficiency Trend'],
['Month', 'Automation %', 'Fill Rate %', 'Response Time (hrs)'],
...efficiencyTrend.map(t => [t.month, t.automation, t.fillRate, t.responseTime]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `operational-efficiency-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Efficiency report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Operational Efficiency & Automation Impact</h2>
<p className="text-sm text-slate-500">Track process improvements and automation effectiveness</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Automation Rate</p>
<p className="text-2xl font-bold text-slate-900">{automationRate}%</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Time to Fill</p>
<p className="text-2xl font-bold text-slate-900">{avgTimeToFill}h</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Response Time</p>
<p className="text-2xl font-bold text-slate-900">{avgResponseTime}h</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Completed</p>
<p className="text-2xl font-bold text-slate-900">{events.filter(e => e.status === 'Completed').length}</p>
</div>
<div className="w-12 h-12 bg-emerald-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-emerald-600" />
</div>
</div>
</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>
);
}

View File

@@ -0,0 +1,226 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, Users, TrendingUp, Clock } from "lucide-react";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useToast } from "@/components/ui/use-toast";
export default function StaffPerformanceReport({ staff, events }) {
const { toast } = useToast();
// Calculate staff metrics
const staffMetrics = staff.map(s => {
const assignments = events.filter(e =>
e.assigned_staff?.some(as => as.staff_id === s.id)
);
const completedShifts = assignments.filter(e => e.status === 'Completed').length;
const totalShifts = s.total_shifts || assignments.length || 1;
const fillRate = totalShifts > 0 ? ((completedShifts / totalShifts) * 100).toFixed(1) : 0;
const reliability = s.reliability_score || s.shift_coverage_percentage || 85;
return {
id: s.id,
name: s.employee_name,
position: s.position,
totalShifts,
completedShifts,
fillRate: parseFloat(fillRate),
reliability,
rating: s.rating || 4.2,
cancellations: s.cancellation_count || 0,
noShows: s.no_show_count || 0,
};
}).sort((a, b) => b.reliability - a.reliability);
// Top performers
const topPerformers = staffMetrics.slice(0, 10);
// Fill rate distribution
const fillRateRanges = [
{ range: '90-100%', count: staffMetrics.filter(s => s.fillRate >= 90).length },
{ range: '80-89%', count: staffMetrics.filter(s => s.fillRate >= 80 && s.fillRate < 90).length },
{ range: '70-79%', count: staffMetrics.filter(s => s.fillRate >= 70 && s.fillRate < 80).length },
{ range: '60-69%', count: staffMetrics.filter(s => s.fillRate >= 60 && s.fillRate < 70).length },
{ range: '<60%', count: staffMetrics.filter(s => s.fillRate < 60).length },
];
const avgReliability = staffMetrics.reduce((sum, s) => sum + s.reliability, 0) / staffMetrics.length || 0;
const avgFillRate = staffMetrics.reduce((sum, s) => sum + s.fillRate, 0) / staffMetrics.length || 0;
const totalCancellations = staffMetrics.reduce((sum, s) => sum + s.cancellations, 0);
const handleExport = () => {
const csv = [
['Staff Performance Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Average Reliability', `${avgReliability.toFixed(1)}%`],
['Average Fill Rate', `${avgFillRate.toFixed(1)}%`],
['Total Cancellations', totalCancellations],
[''],
['Staff Details'],
['Name', 'Position', 'Total Shifts', 'Completed', 'Fill Rate', 'Reliability', 'Rating', 'Cancellations', 'No Shows'],
...staffMetrics.map(s => [
s.name,
s.position,
s.totalShifts,
s.completedShifts,
`${s.fillRate}%`,
`${s.reliability}%`,
s.rating,
s.cancellations,
s.noShows,
]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `staff-performance-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Performance report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staff Performance Metrics</h2>
<p className="text-sm text-slate-500">Reliability, fill rates, and performance tracking</p>
</div>
<Button onClick={handleExport} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Reliability</p>
<p className="text-2xl font-bold text-slate-900">{avgReliability.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Avg Fill Rate</p>
<p className="text-2xl font-bold text-slate-900">{avgFillRate.toFixed(1)}%</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Cancellations</p>
<p className="text-2xl font-bold text-slate-900">{totalCancellations}</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Clock className="w-6 h-6 text-red-600" />
</div>
</div>
</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>
);
}

View File

@@ -0,0 +1,234 @@
import React, { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Download, DollarSign, TrendingUp, AlertCircle } from "lucide-react";
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/use-toast";
const COLORS = ['#0A39DF', '#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'];
export default function StaffingCostReport({ events, invoices }) {
const [dateRange, setDateRange] = useState("30");
const { toast } = useToast();
// Calculate costs by month
const costsByMonth = events.reduce((acc, event) => {
if (!event.date || !event.total) return acc;
const date = new Date(event.date);
const month = date.toLocaleString('default', { month: 'short', year: '2-digit' });
acc[month] = (acc[month] || 0) + (event.total || 0);
return acc;
}, {});
const monthlyData = Object.entries(costsByMonth).map(([month, cost]) => ({
month,
cost: Math.round(cost),
budget: Math.round(cost * 1.1), // 10% buffer
}));
// Costs by department
const costsByDepartment = events.reduce((acc, event) => {
event.shifts?.forEach(shift => {
shift.roles?.forEach(role => {
const dept = role.department || 'Unassigned';
acc[dept] = (acc[dept] || 0) + (role.total_value || 0);
});
});
return acc;
}, {});
const departmentData = Object.entries(costsByDepartment)
.map(([name, value]) => ({ name, value: Math.round(value) }))
.sort((a, b) => b.value - a.value);
// Budget adherence
const totalSpent = events.reduce((sum, e) => sum + (e.total || 0), 0);
const totalBudget = totalSpent * 1.15; // Assume 15% buffer
const adherence = totalBudget > 0 ? ((totalSpent / totalBudget) * 100).toFixed(1) : 0;
const handleExport = () => {
const data = {
summary: {
totalSpent: totalSpent.toFixed(2),
totalBudget: totalBudget.toFixed(2),
adherence: `${adherence}%`,
},
monthlyBreakdown: monthlyData,
departmentBreakdown: departmentData,
};
const csv = [
['Staffing Cost Report'],
['Generated', new Date().toISOString()],
[''],
['Summary'],
['Total Spent', totalSpent.toFixed(2)],
['Total Budget', totalBudget.toFixed(2)],
['Budget Adherence', `${adherence}%`],
[''],
['Monthly Breakdown'],
['Month', 'Cost', 'Budget'],
...monthlyData.map(d => [d.month, d.cost, d.budget]),
[''],
['Department Breakdown'],
['Department', 'Cost'],
...departmentData.map(d => [d.name, d.value]),
].map(row => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `staffing-costs-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({ title: "✅ Report Exported", description: "Cost report downloaded as CSV" });
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-slate-900">Staffing Costs & Budget Adherence</h2>
<p className="text-sm text-slate-500">Track spending and budget compliance</p>
</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} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export CSV
</Button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Spent</p>
<p className="text-2xl font-bold text-slate-900">${totalSpent.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
<DollarSign className="w-6 h-6 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget</p>
<p className="text-2xl font-bold text-slate-900">${totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })}</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Budget Adherence</p>
<p className="text-2xl font-bold text-slate-900">{adherence}%</p>
<Badge className={adherence < 90 ? "bg-green-500" : adherence < 100 ? "bg-amber-500" : "bg-red-500"}>
{adherence < 90 ? "Under Budget" : adherence < 100 ? "On Track" : "Over Budget"}
</Badge>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-purple-600" />
</div>
</div>
</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>
);
}

View File

@@ -0,0 +1,211 @@
import React, { useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/components/ui/use-toast";
import { hasTimeOverlap, checkDoubleBooking } from "./SmartAssignmentEngine";
import { format, addDays } from "date-fns";
/**
* Automation Engine
* Handles background automations to reduce manual work
*/
export function AutomationEngine() {
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: events } = useQuery({
queryKey: ['events-automation'],
queryFn: () => base44.entities.Event.list(),
initialData: [],
refetchInterval: 30000, // Check every 30s
});
const { data: allStaff } = useQuery({
queryKey: ['staff-automation'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
refetchInterval: 60000,
});
const { data: existingInvoices } = useQuery({
queryKey: ['invoices-automation'],
queryFn: () => base44.entities.Invoice.list(),
initialData: [],
refetchInterval: 60000,
});
// Auto-create invoice when event is marked as Completed
useEffect(() => {
const autoCreateInvoices = async () => {
const completedEvents = events.filter(e =>
e.status === 'Completed' &&
!e.invoice_id &&
!existingInvoices.some(inv => inv.event_id === e.id)
);
for (const event of completedEvents) {
try {
const invoiceNumber = `INV-${format(new Date(), 'yyMMddHHmmss')}`;
const issueDate = format(new Date(), 'yyyy-MM-dd');
const dueDate = format(addDays(new Date(), 30), 'yyyy-MM-dd'); // Net 30
const invoice = await base44.entities.Invoice.create({
invoice_number: invoiceNumber,
event_id: event.id,
event_name: event.event_name,
business_name: event.business_name || event.client_name,
vendor_name: event.vendor_name,
manager_name: event.client_name,
hub: event.hub,
cost_center: event.cost_center,
amount: event.total || 0,
item_count: event.assigned_staff?.length || 0,
status: 'Open',
issue_date: issueDate,
due_date: dueDate,
notes: `Auto-generated invoice for completed event: ${event.event_name}`
});
// Update event with invoice_id
await base44.entities.Event.update(event.id, {
invoice_id: invoice.id
});
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['events'] });
} catch (error) {
console.error('Auto-invoice creation failed:', error);
}
}
};
if (events.length > 0) {
autoCreateInvoices();
}
}, [events, existingInvoices, queryClient]);
// Auto-confirm workers (24 hours before shift)
useEffect(() => {
const autoConfirmWorkers = async () => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const upcomingEvents = events.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= now && eventDate <= tomorrow && e.status === 'Assigned';
});
for (const event of upcomingEvents) {
if (event.assigned_staff?.length > 0) {
try {
await base44.entities.Event.update(event.id, {
status: 'Confirmed'
});
// Send confirmation emails
for (const staff of event.assigned_staff) {
await base44.integrations.Core.SendEmail({
to: staff.email,
subject: `Shift Confirmed - ${event.event_name}`,
body: `Your shift at ${event.event_name} on ${event.date} has been confirmed. See you there!`
});
}
} catch (error) {
console.error('Auto-confirm failed:', error);
}
}
}
};
if (events.length > 0) {
autoConfirmWorkers();
}
}, [events]);
// Auto-send reminders (2 hours before shift)
useEffect(() => {
const sendReminders = async () => {
const now = new Date();
const twoHoursLater = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const upcomingEvents = events.filter(e => {
const eventDate = new Date(e.date);
return eventDate >= now && eventDate <= twoHoursLater;
});
for (const event of upcomingEvents) {
if (event.assigned_staff?.length > 0 && event.status === 'Confirmed') {
for (const staff of event.assigned_staff) {
try {
await base44.integrations.Core.SendEmail({
to: staff.email,
subject: `Reminder: Your shift starts in 2 hours`,
body: `Reminder: Your shift at ${event.event_name} starts in 2 hours. Location: ${event.event_location || event.hub}`
});
} catch (error) {
console.error('Reminder failed:', error);
}
}
}
}
};
if (events.length > 0) {
sendReminders();
}
}, [events]);
// Auto-detect overlapping shifts
useEffect(() => {
const detectOverlaps = () => {
const conflicts = [];
allStaff.forEach(staff => {
const staffEvents = events.filter(e =>
e.assigned_staff?.some(s => s.staff_id === staff.id)
);
for (let i = 0; i < staffEvents.length; i++) {
for (let j = i + 1; j < staffEvents.length; j++) {
const e1 = staffEvents[i];
const e2 = staffEvents[j];
const d1 = new Date(e1.date);
const d2 = new Date(e2.date);
if (d1.toDateString() === d2.toDateString()) {
const shift1 = e1.shifts?.[0]?.roles?.[0];
const shift2 = e2.shifts?.[0]?.roles?.[0];
if (shift1 && shift2 && hasTimeOverlap(shift1, shift2)) {
conflicts.push({
staff: staff.employee_name,
event1: e1.event_name,
event2: e2.event_name,
date: e1.date
});
}
}
}
}
});
if (conflicts.length > 0) {
toast({
title: `⚠️ ${conflicts.length} Double-Booking Detected`,
description: `${conflicts[0].staff} has overlapping shifts`,
variant: "destructive",
});
}
};
if (events.length > 0 && allStaff.length > 0) {
detectOverlaps();
}
}, [events, allStaff]);
return null; // Background service
}
export default AutomationEngine;

View File

@@ -0,0 +1,314 @@
import React from "react";
import { format, parseISO, isWithinInterval, addMinutes, subMinutes } from "date-fns";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { AlertTriangle, X, Users, MapPin, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
/**
* Conflict Detection System
* Detects and alerts users to overlapping event bookings
*/
// Parse time string (HH:MM or HH:MM AM/PM) to minutes since midnight
const parseTimeToMinutes = (timeStr) => {
if (!timeStr) return 0;
// Handle 24-hour format
if (timeStr.includes(':') && !timeStr.includes('AM') && !timeStr.includes('PM')) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Handle 12-hour format
const [time, period] = timeStr.split(' ');
let [hours, minutes] = time.split(':').map(Number);
if (period === 'PM' && hours !== 12) hours += 12;
if (period === 'AM' && hours === 12) hours = 0;
return hours * 60 + minutes;
};
// Check if two time ranges overlap (considering buffer)
export const detectTimeOverlap = (start1, end1, start2, end2, bufferMinutes = 0) => {
const s1 = parseTimeToMinutes(start1) - bufferMinutes;
const e1 = parseTimeToMinutes(end1) + bufferMinutes;
const s2 = parseTimeToMinutes(start2);
const e2 = parseTimeToMinutes(end2);
return s1 < e2 && s2 < e1;
};
// Check if two dates are the same or overlap (for multi-day events)
export const detectDateOverlap = (event1, event2) => {
const e1Start = event1.is_multi_day ? parseISO(event1.multi_day_start_date) : parseISO(event1.date);
const e1End = event1.is_multi_day ? parseISO(event1.multi_day_end_date) : parseISO(event1.date);
const e2Start = event2.is_multi_day ? parseISO(event2.multi_day_start_date) : parseISO(event2.date);
const e2End = event2.is_multi_day ? parseISO(event2.multi_day_end_date) : parseISO(event2.date);
return isWithinInterval(e1Start, { start: e2Start, end: e2End }) ||
isWithinInterval(e1End, { start: e2Start, end: e2End }) ||
isWithinInterval(e2Start, { start: e1Start, end: e1End }) ||
isWithinInterval(e2End, { start: e1Start, end: e1End });
};
// Detect staff conflicts
export const detectStaffConflicts = (event, allEvents) => {
const conflicts = [];
if (!event.assigned_staff || event.assigned_staff.length === 0) {
return conflicts;
}
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
for (const staff of event.assigned_staff) {
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
// Check if same staff is assigned
const staffInOther = otherEvent.assigned_staff?.find(s => s.staff_id === staff.staff_id);
if (!staffInOther) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check time overlap
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const hasOverlap = detectTimeOverlap(
eventTimes.start_time,
eventTimes.end_time,
otherTimes.start_time,
otherTimes.end_time,
bufferBefore + bufferAfter
);
if (hasOverlap) {
conflicts.push({
conflict_type: 'staff_overlap',
severity: 'high',
description: `${staff.staff_name} is double-booked with "${otherEvent.event_name}"`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
staff_id: staff.staff_id,
staff_name: staff.staff_name,
detected_at: new Date().toISOString(),
});
}
}
}
return conflicts;
};
// Detect venue conflicts
export const detectVenueConflicts = (event, allEvents) => {
const conflicts = [];
if (!event.event_location && !event.hub) {
return conflicts;
}
const eventLocation = event.event_location || event.hub;
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
const otherLocation = otherEvent.event_location || otherEvent.hub;
if (!otherLocation) continue;
// Check if same location
if (eventLocation.toLowerCase() !== otherLocation.toLowerCase()) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check time overlap
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const hasOverlap = detectTimeOverlap(
eventTimes.start_time,
eventTimes.end_time,
otherTimes.start_time,
otherTimes.end_time,
bufferBefore + bufferAfter
);
if (hasOverlap) {
conflicts.push({
conflict_type: 'venue_overlap',
severity: 'medium',
description: `Venue "${eventLocation}" is already booked for "${otherEvent.event_name}"`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
location: eventLocation,
detected_at: new Date().toISOString(),
});
}
}
return conflicts;
};
// Detect buffer time violations
export const detectBufferViolations = (event, allEvents) => {
const conflicts = [];
if (!event.buffer_time_before && !event.buffer_time_after) {
return conflicts;
}
const eventTimes = event.shifts?.[0]?.roles?.[0] || {};
for (const otherEvent of allEvents) {
if (otherEvent.id === event.id) continue;
if (otherEvent.status === 'Canceled' || otherEvent.status === 'Completed') continue;
// Check if events share staff
const sharedStaff = event.assigned_staff?.filter(s =>
otherEvent.assigned_staff?.some(os => os.staff_id === s.staff_id)
) || [];
if (sharedStaff.length === 0) continue;
// Check date overlap
if (!detectDateOverlap(event, otherEvent)) continue;
// Check if buffer time is violated
const otherTimes = otherEvent.shifts?.[0]?.roles?.[0] || {};
const eventStart = parseTimeToMinutes(eventTimes.start_time);
const eventEnd = parseTimeToMinutes(eventTimes.end_time);
const otherStart = parseTimeToMinutes(otherTimes.start_time);
const otherEnd = parseTimeToMinutes(otherTimes.end_time);
const bufferBefore = event.buffer_time_before || 0;
const bufferAfter = event.buffer_time_after || 0;
const hasViolation =
(otherEnd > eventStart - bufferBefore && otherEnd <= eventStart) ||
(otherStart < eventEnd + bufferAfter && otherStart >= eventEnd);
if (hasViolation) {
conflicts.push({
conflict_type: 'time_buffer',
severity: 'low',
description: `Buffer time violation with "${otherEvent.event_name}" (${sharedStaff.length} shared staff)`,
conflicting_event_id: otherEvent.id,
conflicting_event_name: otherEvent.event_name,
buffer_required: `${bufferBefore + bufferAfter} minutes`,
detected_at: new Date().toISOString(),
});
}
}
return conflicts;
};
// Main conflict detection function
export const detectAllConflicts = (event, allEvents) => {
if (!event.conflict_detection_enabled) return [];
const staffConflicts = detectStaffConflicts(event, allEvents);
const venueConflicts = detectVenueConflicts(event, allEvents);
const bufferViolations = detectBufferViolations(event, allEvents);
return [...staffConflicts, ...venueConflicts, ...bufferViolations];
};
// Conflict Alert Component
export function ConflictAlert({ conflicts, onDismiss }) {
if (!conflicts || conflicts.length === 0) return null;
const getSeverityColor = (severity) => {
switch (severity) {
case 'critical': return 'border-red-600 bg-red-50';
case 'high': return 'border-orange-500 bg-orange-50';
case 'medium': return 'border-amber-500 bg-amber-50';
case 'low': return 'border-blue-500 bg-blue-50';
default: return 'border-slate-300 bg-slate-50';
}
};
const getSeverityIcon = (severity) => {
switch (severity) {
case 'critical':
case 'high': return <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,
};

View File

@@ -0,0 +1,120 @@
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>
);
}

View File

@@ -0,0 +1,182 @@
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: '' };
};

View File

@@ -0,0 +1,343 @@
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}
/>
</>
);
}

View File

@@ -0,0 +1,221 @@
/**
* 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"
};
}
}

View File

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

View File

@@ -0,0 +1,218 @@
import React, { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Users, TrendingUp, UserPlus, AlertCircle, CheckCircle } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function TalentRadar({ event, allStaff, availabilityData, onAssign }) {
// Calculate three intelligent buckets
const buckets = useMemo(() => {
const readyNow = [];
const likelyAvailable = [];
const needsWork = [];
allStaff.forEach(staff => {
const availability = availabilityData.find(a => a.staff_id === staff.id);
// Skip if already assigned to this event
if (event.assigned_staff?.some(a => a.staff_id === staff.id)) return;
// Skip if blocked
if (availability?.availability_status === 'BLOCKED') return;
// Calculate match score
const matchScore = calculateMatchScore(staff, event, availability);
// Ready Now: Confirmed available + good match
if (availability?.availability_status === 'CONFIRMED_AVAILABLE' && matchScore >= 70) {
readyNow.push({ staff, availability, matchScore });
}
// Likely Available: Unknown but high prediction score
else if (
availability?.availability_status === 'UNKNOWN' &&
availability?.predicted_availability_score >= 70
) {
likelyAvailable.push({ staff, availability, matchScore });
}
// Needs Work: High need index
if (availability && availability.need_work_index >= 60) {
needsWork.push({ staff, availability, matchScore });
}
});
// Sort by match score and need
readyNow.sort((a, b) => {
const scoreA = a.matchScore + (a.availability?.need_work_index || 0) * 0.3;
const scoreB = b.matchScore + (b.availability?.need_work_index || 0) * 0.3;
return scoreB - scoreA;
});
likelyAvailable.sort((a, b) => b.matchScore - a.matchScore);
needsWork.sort((a, b) => b.availability.need_work_index - a.availability.need_work_index);
return { readyNow, likelyAvailable, needsWork };
}, [allStaff, availabilityData, event]);
const renderWorkerCard = (item, bucket) => {
const { staff, availability, matchScore } = item;
return (
<Card key={staff.id} className="border hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-blue-500 text-white font-bold text-sm">
{staff.employee_name?.charAt(0)?.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-slate-900 truncate">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position || 'Staff'}</p>
<div className="flex gap-1 mt-2 flex-wrap">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{matchScore}% match
</Badge>
{availability?.scheduled_hours_this_period > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{availability.scheduled_hours_this_period}h scheduled
</Badge>
)}
{bucket === 'needs' && (
<Badge className="bg-orange-500 text-white text-[10px] px-1.5 py-0">
Needs work
</Badge>
)}
{bucket === 'likely' && (
<Badge className="bg-blue-500 text-white text-[10px] px-1.5 py-0">
{availability?.predicted_availability_score}% likely
</Badge>
)}
</div>
</div>
</div>
<Button
size="sm"
onClick={() => onAssign(staff, bucket)}
className="flex-shrink-0"
>
Assign
</Button>
</div>
</CardContent>
</Card>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-slate-900">Talent Radar</h3>
<p className="text-sm text-slate-500">Smart worker recommendations for this shift</p>
</div>
</div>
<Tabs defaultValue="ready" className="space-y-4">
<TabsList>
<TabsTrigger value="ready" className="flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
Ready Now ({buckets.readyNow.length})
</TabsTrigger>
<TabsTrigger value="likely" className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
Likely Available ({buckets.likelyAvailable.length})
</TabsTrigger>
<TabsTrigger value="needs" className="flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Needs Work ({buckets.needsWork.length})
</TabsTrigger>
</TabsList>
<TabsContent value="ready" className="space-y-3">
<Card className="bg-green-50 border-green-200">
<CardContent className="p-4">
<p className="text-sm text-green-800">
<strong>Ready Now:</strong> These workers have confirmed their availability and are the best match for this shift.
</p>
</CardContent>
</Card>
{buckets.readyNow.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No workers confirmed available</p>
) : (
<div className="grid gap-3">
{buckets.readyNow.map(item => renderWorkerCard(item, 'ready'))}
</div>
)}
</TabsContent>
<TabsContent value="likely" className="space-y-3">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="p-4">
<p className="text-sm text-blue-800">
<strong>Likely Available:</strong> These workers haven't confirmed availability but historically accept similar shifts. Assignment requires worker confirmation.
</p>
</CardContent>
</Card>
{buckets.likelyAvailable.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No predictions available</p>
) : (
<div className="grid gap-3">
{buckets.likelyAvailable.map(item => renderWorkerCard(item, 'likely'))}
</div>
)}
</TabsContent>
<TabsContent value="needs" className="space-y-3">
<Card className="bg-orange-50 border-orange-200">
<CardContent className="p-4">
<p className="text-sm text-orange-800">
<strong>Needs Work:</strong> These workers are under-scheduled and could benefit from additional hours. They may accept even if not explicitly available.
</p>
</CardContent>
</Card>
{buckets.needsWork.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">No under-utilized workers</p>
) : (
<div className="grid gap-3">
{buckets.needsWork.map(item => renderWorkerCard(item, 'needs'))}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}
// Helper function to calculate match score
function calculateMatchScore(staff, event, availability) {
let score = 50; // Base score
// Skill match
const eventRole = event.shifts?.[0]?.roles?.[0]?.role;
if (eventRole && staff.position === eventRole) {
score += 20;
} else if (eventRole && staff.position_2 === eventRole) {
score += 10;
}
// Reliability
if (staff.reliability_score) {
score += (staff.reliability_score / 100) * 15;
}
// Compliance
if (staff.background_check_status === 'cleared') {
score += 10;
}
// Acceptance rate
if (availability?.acceptance_rate) {
score += (availability.acceptance_rate / 100) * 5;
}
return Math.min(100, Math.round(score));
}

View File

@@ -0,0 +1,137 @@
import React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Star, MapPin, Clock, Award, TrendingUp, AlertCircle } from "lucide-react";
/**
* Worker Info Hover Card
* Shows comprehensive staff info: role, ratings, history, reliability
*/
export default function WorkerInfoCard({ staff, trigger }) {
if (!staff) return trigger;
const reliabilityColor = (score) => {
if (score >= 95) return "text-green-600";
if (score >= 85) return "text-amber-600";
return "text-red-600";
};
return (
<HoverCard>
<HoverCardTrigger asChild>
{trigger}
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<Avatar className="w-14 h-14">
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-blue-700 text-white font-bold text-lg">
{staff.employee_name?.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-bold text-slate-900">{staff.employee_name}</h4>
<p className="text-sm text-slate-600">{staff.position}</p>
{staff.position_2 && (
<p className="text-xs text-slate-500">Also: {staff.position_2}</p>
)}
</div>
</div>
{/* Rating & Reliability */}
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 bg-amber-50 rounded-lg p-2">
<Star className="w-4 h-4 text-amber-500" />
<div>
<p className="text-xs text-slate-500">Rating</p>
<p className="font-bold text-amber-700">{staff.rating || 4.5} </p>
</div>
</div>
<div className="flex items-center gap-2 bg-green-50 rounded-lg p-2">
<TrendingUp className="w-4 h-4 text-green-600" />
<div>
<p className="text-xs text-slate-500">Reliability</p>
<p className={`font-bold ${reliabilityColor(staff.reliability_score || 90)}`}>
{staff.reliability_score || 90}%
</p>
</div>
</div>
</div>
{/* Experience & History */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">
{staff.total_shifts || 0} shifts completed
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">{staff.hub_location || staff.city || "Unknown location"}</span>
</div>
{staff.certifications && staff.certifications.length > 0 && (
<div className="flex items-center gap-2 text-sm">
<Award className="w-4 h-4 text-slate-400" />
<span className="text-slate-600">
{staff.certifications.length} certification{staff.certifications.length > 1 ? 's' : ''}
</span>
</div>
)}
</div>
{/* Certifications */}
{staff.certifications && staff.certifications.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-semibold text-slate-700 uppercase">Certifications</p>
<div className="flex flex-wrap gap-1">
{staff.certifications.slice(0, 3).map((cert, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{cert.name || cert.cert_name}
</Badge>
))}
</div>
</div>
)}
{/* Performance Indicators */}
<div className="grid grid-cols-3 gap-2 pt-2 border-t">
<div className="text-center">
<p className="text-xs text-slate-500">On-Time</p>
<p className="font-bold text-sm text-green-600">
{staff.shift_coverage_percentage || 95}%
</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">No-Shows</p>
<p className="font-bold text-sm text-slate-700">
{staff.no_show_count || 0}
</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">Cancels</p>
<p className="font-bold text-sm text-slate-700">
{staff.cancellation_count || 0}
</p>
</div>
</div>
{/* Warnings */}
{staff.background_check_status !== 'cleared' && (
<div className="flex items-center gap-2 bg-red-50 rounded-lg p-2">
<AlertCircle className="w-4 h-4 text-red-600" />
<p className="text-xs text-red-600">Background check pending</p>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -0,0 +1,273 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Mail, Phone, MapPin, Calendar, Edit, User,
Star, TrendingUp, XCircle, CheckCircle, Home, UserX
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format } from "date-fns";
const getInitials = (name) => {
if (!name) return "?";
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
};
const renderStars = (rating) => {
const stars = [];
const fullStars = Math.floor(rating || 0);
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
stars.push(<Star key={i} className="w-4 h-4 fill-amber-400 text-amber-400" />);
} else {
stars.push(<Star key={i} className="w-4 h-4 text-slate-300" />);
}
}
return stars;
};
const getReliabilityColor = (score) => {
if (score >= 90) return { bg: 'bg-green-500', text: 'text-green-700', bgLight: 'bg-green-50' };
if (score >= 70) return { bg: 'bg-yellow-500', text: 'text-yellow-700', bgLight: 'bg-yellow-50' };
if (score >= 50) return { bg: 'bg-orange-500', text: 'text-orange-700', bgLight: 'bg-orange-50' };
return { bg: 'bg-red-500', text: 'text-red-700', bgLight: 'bg-red-50' };
};
const calculateReliability = (staff) => {
const coverageScore = staff.shift_coverage_percentage || 0;
const cancellationPenalty = (staff.cancellation_count || 0) * 5;
const ratingBonus = ((staff.rating || 0) / 5) * 20;
let reliability = coverageScore + ratingBonus - cancellationPenalty;
reliability = Math.max(0, Math.min(100, reliability));
return Math.round(reliability);
};
export default function EmployeeCard({ staff }) {
const navigate = useNavigate();
const coveragePercentage = staff.shift_coverage_percentage || 0;
const cancellationCount = staff.cancellation_count || 0;
const noShowCount = staff.no_show_count || 0;
const rating = staff.rating || 0;
const reliabilityScore = staff.reliability_score || calculateReliability(staff);
const reliabilityColors = getReliabilityColor(reliabilityScore);
return (
<Card className="bg-white border-slate-200 shadow-lg hover:shadow-xl transition-all">
<CardContent className="p-6 space-y-4">
{/* Header: Name + Position + Edit */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-14 h-14 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold text-xl shadow-md">
{staff.initial || getInitials(staff.employee_name)}
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">
{staff.employee_name}
</h3>
<p className="text-[#0A39DF] font-semibold text-sm">{staff.position || 'Staff'}</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditStaff?id=${staff.id}`))}
className="hover:bg-slate-100"
>
<Edit className="w-4 h-4 text-slate-400" />
</Button>
</div>
{/* Rating */}
<div className="flex items-center gap-2">
{renderStars(rating)}
<span className="text-sm font-semibold text-slate-600 ml-1">({rating.toFixed(1)})</span>
</div>
{/* Reliability Bar */}
<div className={`p-3 rounded-lg ${reliabilityColors.bgLight}`}>
<div className="flex items-center justify-between mb-2">
<span className={`text-xs font-semibold ${reliabilityColors.text}`}>Reliability Score</span>
<span className={`text-lg font-bold ${reliabilityColors.text}`}>{reliabilityScore}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden">
<div
className={`h-full ${reliabilityColors.bg} transition-all duration-500`}
style={{ width: `${reliabilityScore}%` }}
/>
</div>
</div>
{/* Metrics Grid: Coverage, Cancellations, No Shows */}
<div className="grid grid-cols-3 gap-2">
<div className={`p-3 rounded-lg text-center ${
coveragePercentage >= 90 ? 'bg-green-50' :
coveragePercentage >= 70 ? 'bg-yellow-50' :
'bg-red-50'
}`}>
<TrendingUp className={`w-4 h-4 mx-auto mb-1 ${
coveragePercentage >= 90 ? 'text-green-600' :
coveragePercentage >= 70 ? 'text-yellow-600' :
'text-red-600'
}`} />
<div className={`text-2xl font-bold ${
coveragePercentage >= 90 ? 'text-green-700' :
coveragePercentage >= 70 ? 'text-yellow-700' :
'text-red-700'
}`}>
{coveragePercentage}%
</div>
<p className="text-xs text-slate-600 mt-1">Coverage</p>
</div>
<div className={`p-3 rounded-lg text-center ${
cancellationCount === 0 ? 'bg-green-50' :
cancellationCount <= 2 ? 'bg-yellow-50' :
'bg-red-50'
}`}>
<XCircle className={`w-4 h-4 mx-auto mb-1 ${
cancellationCount === 0 ? 'text-green-600' :
cancellationCount <= 2 ? 'text-yellow-600' :
'text-red-600'
}`} />
<div className={`text-2xl font-bold ${
cancellationCount === 0 ? 'text-green-700' :
cancellationCount <= 2 ? 'text-yellow-700' :
'text-red-700'
}`}>
{cancellationCount}
</div>
<p className="text-xs text-slate-600 mt-1">Cancels</p>
</div>
<div className={`p-3 rounded-lg text-center ${
noShowCount === 0 ? 'bg-green-50' :
noShowCount <= 1 ? 'bg-yellow-50' :
'bg-red-50'
}`}>
<UserX className={`w-4 h-4 mx-auto mb-1 ${
noShowCount === 0 ? 'text-green-600' :
noShowCount <= 1 ? 'text-yellow-600' :
'text-red-600'
}`} />
<div className={`text-2xl font-bold ${
noShowCount === 0 ? 'text-green-700' :
noShowCount <= 1 ? 'text-yellow-700' :
'text-red-700'
}`}>
{noShowCount}
</div>
<p className="text-xs text-slate-600 mt-1">No Shows</p>
</div>
</div>
{/* Position Badges (removed "Skills" label) */}
{(staff.position || staff.position_2) && (
<div className="flex flex-wrap gap-2">
{staff.position && (
<Badge className="bg-[#0A39DF] text-white font-medium">
{staff.position}
</Badge>
)}
{staff.position_2 && (
<Badge className="bg-slate-100 text-slate-700 border-slate-300 font-medium">
{staff.position_2}
</Badge>
)}
</div>
)}
{/* English Level & Profile Type */}
<div className="flex flex-wrap gap-2">
{staff.profile_type && (
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-300 font-medium">
{staff.profile_type}
</Badge>
)}
{staff.english && (
<Badge variant="outline" className={`border-2 ${
staff.english === 'Fluent' ? 'bg-green-50 text-green-700 border-green-300' :
staff.english === 'None' ? 'bg-slate-50 text-slate-600 border-slate-300' :
'bg-blue-50 text-blue-700 border-blue-300'
}`}>
{staff.english}
</Badge>
)}
{staff.invoiced && (
<Badge className="bg-emerald-500 text-white">
<CheckCircle className="w-3 h-3 mr-1" />
Invoiced
</Badge>
)}
</div>
{/* Contact Information */}
<div className="space-y-2 pt-2 border-t border-slate-200">
{staff.manager && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<User className="w-4 h-4 text-[#0A39DF]" />
<span className="font-semibold">Manager:</span>
<span>{staff.manager}</span>
</div>
)}
{staff.contact_number && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Phone className="w-4 h-4 text-[#0A39DF]" />
<span>{staff.contact_number}</span>
</div>
)}
{staff.email && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Mail className="w-4 h-4 text-[#0A39DF]" />
<span className="truncate">{staff.email}</span>
</div>
)}
{staff.address && (
<div className="flex items-start gap-2 text-sm text-slate-700">
<Home className="w-4 h-4 text-[#0A39DF] mt-0.5" />
<span>{staff.address}</span>
</div>
)}
{staff.city && !staff.address && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Home className="w-4 h-4 text-[#0A39DF]" />
<span>{staff.city}</span>
</div>
)}
{staff.hub_location && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<MapPin className="w-4 h-4 text-[#0A39DF]" />
<span>{staff.hub_location}</span>
</div>
)}
{staff.check_in && (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Calendar className="w-4 h-4 text-[#0A39DF]" />
<span className="font-semibold">Last Check-in:</span>
<span>{format(new Date(staff.check_in), "MMM d, yyyy")}</span>
</div>
)}
</div>
{/* Schedule */}
{staff.schedule_days && (
<div className="pt-2 border-t border-slate-200">
<p className="text-xs font-semibold text-slate-500 mb-1">Schedule</p>
<p className="text-sm text-slate-900 font-medium">{staff.schedule_days}</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, Filter } from "lucide-react";
export default function FilterBar({ searchTerm, setSearchTerm, departmentFilter, setDepartmentFilter, locationFilter, setLocationFilter, locations }) {
return (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
placeholder="Search by name, position, or manager..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 border-slate-200 focus:border-blue-500 transition-colors"
/>
</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-slate-500" />
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<SelectTrigger className="w-40 border-slate-200">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Sales">Sales</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
<SelectItem value="IT">IT</SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem>
<SelectItem value="Customer Service">Customer Service</SelectItem>
<SelectItem value="Logistics">Logistics</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={locationFilter} onValueChange={setLocationFilter}>
<SelectTrigger className="w-40 border-slate-200">
<SelectValue placeholder="Location" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Locations</SelectItem>
{locations.map(location => (
<SelectItem key={location} value={location}>{location}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import React from "react";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Mail, Phone, MapPin, Calendar, Edit, Building2, Navigation, Route, Star, TrendingUp, XCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { format } from "date-fns";
const departmentColors = {
Operations: "bg-[#0A39DF]/10 text-[#0A39DF] border-[#0A39DF]/30",
Sales: "bg-emerald-100 text-emerald-800 border-emerald-300",
HR: "bg-[#C8DBDC]/40 text-[#1C323E] border-[#C8DBDC]",
Finance: "bg-purple-100 text-purple-800 border-purple-300",
IT: "bg-[#1C323E]/10 text-[#1C323E] border-[#1C323E]/30",
Marketing: "bg-pink-100 text-pink-800 border-pink-300",
"Customer Service": "bg-blue-100 text-blue-800 border-blue-300",
Logistics: "bg-amber-100 text-amber-800 border-amber-300"
};
export default function StaffCard({ staff }) {
const navigate = useNavigate();
const getInitials = (name) => {
if (!name) return "?";
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
};
const renderStars = (rating) => {
const stars = [];
const fullStars = Math.floor(rating || 0);
const hasHalfStar = (rating || 0) % 1 >= 0.5;
for (let i = 0; i < 5; i++) {
if (i < fullStars) {
stars.push(<Star key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" />);
} else if (i === fullStars && hasHalfStar) {
stars.push(<Star key={i} className="w-4 h-4 fill-yellow-400 text-yellow-400" style={{clipPath: 'inset(0 50% 0 0)'}} />);
} else {
stars.push(<Star key={i} className="w-4 h-4 text-slate-300" />);
}
}
return stars;
};
const coveragePercentage = staff.shift_coverage_percentage || 0;
const cancellationCount = staff.cancellation_count || 0;
const rating = staff.rating || 0;
const getCoverageColor = (percentage) => {
if (percentage >= 90) return "text-green-600 bg-green-50";
if (percentage >= 70) return "text-yellow-600 bg-yellow-50";
return "text-red-600 bg-red-50";
};
return (
<Card className="hover:shadow-xl transition-all duration-300 border-slate-200 overflow-hidden group hover:-translate-y-1">
<CardHeader className="bg-gradient-to-br from-slate-50 via-white to-slate-50 p-6 border-b border-slate-200">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg group-hover:scale-110 transition-transform duration-300">
{staff.initial || getInitials(staff.employee_name)}
</div>
<div>
<h3 className="font-bold text-[#1C323E] text-lg group-hover:text-[#0A39DF] transition-colors">
{staff.employee_name}
</h3>
<p className="text-slate-600 font-medium">{staff.position}</p>
{/* Rating */}
<div className="flex items-center gap-1 mt-2">
{renderStars(rating)}
<span className="text-sm text-slate-600 ml-1">({rating.toFixed(1)})</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(createPageUrl(`EditStaff?id=${staff.id}`))}
className="hover:bg-[#0A39DF]/10 hover:text-[#0A39DF]"
>
<Edit className="w-4 h-4" />
</Button>
</div>
</CardHeader>
<CardContent className="p-6 space-y-4">
{/* Performance Metrics */}
<div className="grid grid-cols-2 gap-3 pb-4 border-b border-slate-200">
<div className={`p-3 rounded-lg ${getCoverageColor(coveragePercentage)}`}>
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4" />
<span className="text-xs font-medium">Coverage</span>
</div>
<div className="text-2xl font-bold">{coveragePercentage}%</div>
</div>
<div className={`p-3 rounded-lg ${cancellationCount > 5 ? 'bg-red-50 text-red-600' : cancellationCount > 2 ? 'bg-yellow-50 text-yellow-600' : 'bg-green-50 text-green-600'}`}>
<div className="flex items-center gap-2 mb-1">
<XCircle className="w-4 h-4" />
<span className="text-xs font-medium">Cancellations</span>
</div>
<div className="text-2xl font-bold">{cancellationCount}</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{staff.department && (
<Badge className={`${departmentColors[staff.department]} border font-medium`}>
<Building2 className="w-3 h-3 mr-1" />
{staff.department}
</Badge>
)}
{staff.english && (
<Badge variant="outline" className="border-slate-300 text-slate-700">
English: {staff.english}
</Badge>
)}
{staff.invoiced && (
<Badge className="bg-green-50 text-green-700 border-green-200 border">
Invoiced
</Badge>
)}
</div>
<div className="space-y-3 text-sm">
{staff.manager && (
<div className="flex items-center gap-2 text-slate-600">
<Mail className="w-4 h-4 text-[#0A39DF]" />
<span className="font-medium">Manager:</span>
<span>{staff.manager}</span>
</div>
)}
{staff.contact_number && (
<div className="flex items-center gap-2 text-slate-600">
<Phone className="w-4 h-4 text-[#0A39DF]" />
<span>{staff.contact_number}</span>
</div>
)}
{staff.hub_location && (
<div className="flex items-center gap-2 text-slate-600">
<MapPin className="w-4 h-4 text-[#0A39DF]" />
<span>{staff.hub_location}</span>
</div>
)}
{staff.event_location && (
<div className="flex items-center gap-2 text-slate-600">
<Navigation className="w-4 h-4 text-[#0A39DF]" />
<span className="font-medium">Event:</span>
<span>{staff.event_location}</span>
</div>
)}
{staff.track && (
<div className="flex items-center gap-2 text-slate-600">
<Route className="w-4 h-4 text-[#0A39DF]" />
<span className="font-medium">Track:</span>
<span>{staff.track}</span>
</div>
)}
{staff.check_in && (
<div className="flex items-center gap-2 text-slate-600">
<Calendar className="w-4 h-4 text-[#0A39DF]" />
<span className="font-medium">Last Check-in:</span>
<span>{format(new Date(staff.check_in), "MMM d, yyyy")}</span>
</div>
)}
</div>
{staff.schedule_days && (
<div className="pt-3 border-t border-slate-200">
<p className="text-xs text-slate-500 font-medium mb-1">Schedule</p>
<p className="text-sm text-[#1C323E]">{staff.schedule_days}</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,536 @@
import React, { useState, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Save, Loader2 } from "lucide-react";
export default function StaffForm({ staff, onSubmit, isSubmitting }) {
const [formData, setFormData] = useState(staff || {
employee_name: "",
manager: "",
contact_number: "",
phone: "",
email: "", // Added email field
department: "",
hub_location: "",
event_location: "",
track: "",
address: "",
city: "",
position: "",
position_2: "",
initial: "",
profile_type: "",
employment_type: "",
english: "",
english_required: false,
check_in: "",
replaced_by: "",
ro: "",
mon: "",
schedule_days: "",
invoiced: false,
action: "",
notes: "",
accounting_comments: "",
rating: 0,
shift_coverage_percentage: 100,
cancellation_count: 0,
no_show_count: 0, // Added no_show_count field
total_shifts: 0,
reliability_score: 100
});
useEffect(() => {
if (staff) {
setFormData(staff);
}
}, [staff]);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
return (
<form onSubmit={handleSubmit}>
<div className="space-y-6">
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Basic Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="employee_name" className="text-slate-700 font-medium">Employee Name *</Label>
<Input
id="employee_name"
value={formData.employee_name}
onChange={(e) => handleChange('employee_name', e.target.value)}
required
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="initial" className="text-slate-700 font-medium">Initials</Label>
<Input
id="initial"
value={formData.initial}
onChange={(e) => handleChange('initial', e.target.value)}
maxLength={3}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="position" className="text-slate-700 font-medium">Primary Skill</Label>
<Input
id="position"
value={formData.position}
onChange={(e) => handleChange('position', e.target.value)}
placeholder="e.g., Barista, Server, Cook"
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="position_2" className="text-slate-700 font-medium">Secondary Skill</Label>
<Input
id="position_2"
value={formData.position_2}
onChange={(e) => handleChange('position_2', e.target.value)}
placeholder="e.g., Dishwasher, Prep Cook"
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="profile_type" className="text-slate-700 font-medium">Skill Level</Label>
<Select value={formData.profile_type} onValueChange={(value) => handleChange('profile_type', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select skill level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Skilled">Skilled</SelectItem>
<SelectItem value="Beginner">Beginner</SelectItem>
<SelectItem value="Cross-Trained">Cross-Trained</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_type" className="text-slate-700 font-medium">Employment Type</Label>
<Select value={formData.employment_type} onValueChange={(value) => handleChange('employment_type', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select type" />
</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="Weekends">Weekends</SelectItem>
<SelectItem value="Specific Days">Specific Days</SelectItem>
<SelectItem value="Seasonal">Seasonal</SelectItem>
<SelectItem value="Medical Leave">Medical Leave</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="manager" className="text-slate-700 font-medium">Manager</Label>
<Select value={formData.manager} onValueChange={(value) => handleChange('manager', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select manager" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Fernando">Fernando</SelectItem>
<SelectItem value="Maria">Maria</SelectItem>
<SelectItem value="Paola">Paola</SelectItem>
<SelectItem value="Luis">Luis</SelectItem>
<SelectItem value="Jesus">Jesus</SelectItem>
</SelectContent>
</Select>
</div>
{/* New Email field */}
<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="employee@example.com"
className="border-slate-200"
/>
</div>
{/* End new Email field */}
<div className="space-y-2">
<Label htmlFor="contact_number" className="text-slate-700 font-medium">Contact Number</Label>
<Input
id="contact_number"
type="tel"
value={formData.contact_number}
onChange={(e) => handleChange('contact_number', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone" className="text-slate-700 font-medium">Additional Phone</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="border-slate-200"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Performance Metrics</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="rating" className="text-slate-700 font-medium">Rating (0-5 stars)</Label>
<Input
id="rating"
type="number"
min="0"
max="5"
step="0.1"
value={formData.rating}
onChange={(e) => handleChange('rating', parseFloat(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reliability_score" className="text-slate-700 font-medium">Reliability Score (0-100)</Label>
<Input
id="reliability_score"
type="number"
min="0"
max="100"
value={formData.reliability_score}
onChange={(e) => handleChange('reliability_score', parseInt(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="shift_coverage_percentage" className="text-slate-700 font-medium">Shift Coverage %</Label>
<Input
id="shift_coverage_percentage"
type="number"
min="0"
max="100"
value={formData.shift_coverage_percentage}
onChange={(e) => handleChange('shift_coverage_percentage', parseInt(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cancellation_count" className="text-slate-700 font-medium">Cancellation Count</Label>
<Input
id="cancellation_count"
type="number"
min="0"
value={formData.cancellation_count}
onChange={(e) => handleChange('cancellation_count', parseInt(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
{/* New No Show Count field */}
<div className="space-y-2">
<Label htmlFor="no_show_count" className="text-slate-700 font-medium">No Show Count</Label>
<Input
id="no_show_count"
type="number"
min="0"
value={formData.no_show_count}
onChange={(e) => handleChange('no_show_count', parseInt(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
{/* End new No Show Count field */}
<div className="space-y-2">
<Label htmlFor="total_shifts" className="text-slate-700 font-medium">Total Shifts</Label>
<Input
id="total_shifts"
type="number"
min="0"
value={formData.total_shifts}
onChange={(e) => handleChange('total_shifts', parseInt(e.target.value) || 0)}
className="border-slate-200"
/>
</div>
<div className="space-y-2 flex items-end">
<div className="flex items-center space-x-2">
<Checkbox
id="invoiced"
checked={formData.invoiced}
onCheckedChange={(checked) => handleChange('invoiced', checked)}
/>
<Label htmlFor="invoiced" className="text-slate-700 font-medium cursor-pointer">
Invoiced
</Label>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Location & Department</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="department" className="text-slate-700 font-medium">Department</Label>
<Select value={formData.department} onValueChange={(value) => handleChange('department', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Operations">Operations</SelectItem>
<SelectItem value="Sales">Sales</SelectItem>
<SelectItem value="HR">HR</SelectItem>
<SelectItem value="Finance">Finance</SelectItem>
<SelectItem value="IT">IT</SelectItem>
<SelectItem value="Marketing">Marketing</SelectItem>
<SelectItem value="Customer Service">Customer Service</SelectItem>
<SelectItem value="Logistics">Logistics</SelectItem>
</SelectContent>
</Select>
</div>
<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="e.g., San Francisco"
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hub_location" className="text-slate-700 font-medium">Hub Location</Label>
<Input
id="hub_location"
value={formData.hub_location}
onChange={(e) => handleChange('hub_location', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="event_location" className="text-slate-700 font-medium">Event Location</Label>
<Input
id="event_location"
value={formData.event_location}
onChange={(e) => handleChange('event_location', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="track" className="text-slate-700 font-medium">Track</Label>
<Input
id="track"
value={formData.track}
onChange={(e) => handleChange('track', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address" className="text-slate-700 font-medium">Address</Label>
<Textarea
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
rows={2}
className="border-slate-200"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Language & Schedule</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="english" className="text-slate-700 font-medium">English Level</Label>
<Select value={formData.english} onValueChange={(value) => handleChange('english', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select level" />
</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 className="space-y-2 flex items-end">
<div className="flex items-center space-x-2">
<Checkbox
id="english_required"
checked={formData.english_required}
onCheckedChange={(checked) => handleChange('english_required', checked)}
/>
<Label htmlFor="english_required" className="text-slate-700 font-medium cursor-pointer">
English Required
</Label>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="check_in" className="text-slate-700 font-medium">Last Check-in</Label>
<Input
id="check_in"
type="date"
value={formData.check_in}
onChange={(e) => handleChange('check_in', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="schedule_days" className="text-slate-700 font-medium">Schedule Days</Label>
<Input
id="schedule_days"
value={formData.schedule_days}
onChange={(e) => handleChange('schedule_days', e.target.value)}
placeholder="e.g., Mon-Fri, 9AM-5PM"
className="border-slate-200"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="border-slate-200 shadow-lg">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-slate-900">Additional Information</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="replaced_by" className="text-slate-700 font-medium">Replaced By</Label>
<Input
id="replaced_by"
value={formData.replaced_by}
onChange={(e) => handleChange('replaced_by', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="action" className="text-slate-700 font-medium">Action</Label>
<Input
id="action"
value={formData.action}
onChange={(e) => handleChange('action', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ro" className="text-slate-700 font-medium">R.O</Label>
<Input
id="ro"
value={formData.ro}
onChange={(e) => handleChange('ro', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mon" className="text-slate-700 font-medium">MON</Label>
<Input
id="mon"
value={formData.mon}
onChange={(e) => handleChange('mon', e.target.value)}
className="border-slate-200"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes" className="text-slate-700 font-medium">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => handleChange('notes', e.target.value)}
rows={3}
className="border-slate-200"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="accounting_comments" className="text-slate-700 font-medium">Accounting Comments</Label>
<Textarea
id="accounting_comments"
value={formData.accounting_comments}
onChange={(e) => handleChange('accounting_comments', e.target.value)}
rows={3}
className="border-slate-200"
/>
</div>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Button
type="submit"
disabled={isSubmitting}
className="bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Staff Member
</>
)}
</Button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TrendingUp } from "lucide-react";
export default function StatsCard({ title, value, icon: Icon, gradient, change, textColor = "text-white" }) {
return (
<Card className="relative overflow-hidden border-0 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
<div className={`absolute inset-0 ${gradient} opacity-100`} />
<div className="absolute top-0 right-0 w-32 h-32 transform translate-x-8 -translate-y-8 bg-white/10 rounded-full" />
<CardHeader className="p-6 relative z-10">
<div className="flex justify-between items-start">
<div>
<p className={`text-sm font-medium mb-2 ${textColor === "text-white" ? "text-white/80" : "text-[#1C323E]/70"}`}>
{title}
</p>
<div className={`text-3xl font-bold ${textColor}`}>
{value}
</div>
</div>
<div className="p-3 rounded-xl bg-white/20 backdrop-blur-sm">
<Icon className={`w-6 h-6 ${textColor}`} />
</div>
</div>
{change && (
<div className="flex items-center mt-4 text-sm">
<TrendingUp className={`w-4 h-4 mr-1 ${textColor === "text-white" ? "text-white" : "text-green-600"}`} />
<span className={`font-medium ${textColor === "text-white" ? "text-white" : "text-green-600"}`}>
{change}
</span>
</div>
)}
</CardHeader>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MoreVertical, Paperclip, MessageSquare, Calendar } from "lucide-react";
import { format } from "date-fns";
const priorityConfig = {
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
low: { bg: "bg-orange-100", text: "text-orange-700", label: "Low" }
};
const progressColor = (progress) => {
if (progress >= 75) return "bg-teal-500";
if (progress >= 50) return "bg-blue-500";
if (progress >= 25) return "bg-amber-500";
return "bg-slate-400";
};
export default function TaskCard({ task, provided, onClick, itemHeight = "normal", conditionalColoring = true }) {
const heightClasses = {
compact: "p-2",
normal: "p-4",
comfortable: "p-5"
};
const cardHeight = heightClasses[itemHeight] || heightClasses.normal;
const priority = priorityConfig[task.priority] || priorityConfig.normal;
const priorityBorder = conditionalColoring && task.priority === 'high' ? 'border-l-4 border-l-red-500' : '';
const priorityBg = conditionalColoring && task.priority === 'high' ? 'bg-red-50/50' : 'bg-white';
return (
<Card
ref={provided?.innerRef}
{...provided?.draggableProps}
{...provided?.dragHandleProps}
onClick={onClick}
className={`${priorityBg} border border-slate-200 ${priorityBorder} hover:shadow-md transition-all cursor-pointer mb-3`}
>
<div className={cardHeight}>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<h4 className="font-semibold text-slate-900 text-sm flex-1">{task.task_name}</h4>
<button className="text-slate-400 hover:text-slate-600">
<MoreVertical className="w-4 h-4" />
</button>
</div>
{/* Priority & Date */}
<div className="flex items-center justify-between mb-3">
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold`}>
{priority.label}
</Badge>
{task.due_date && (
<div className="flex items-center gap-1 text-xs text-slate-500">
<Calendar className="w-3 h-3" />
{format(new Date(task.due_date), 'd MMM')}
</div>
)}
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${progressColor(task.progress || 0)} transition-all`}
style={{ width: `${task.progress || 0}%` }}
/>
</div>
<span className="text-xs font-semibold text-slate-600 ml-3">{task.progress || 0}%</span>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between">
{/* Assigned Members */}
<div className="flex -space-x-2">
{(task.assigned_members || []).slice(0, 3).map((member, idx) => (
<Avatar key={idx} className="w-7 h-7 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{(task.assigned_members?.length || 0) > 3 && (
<div className="w-7 h-7 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-semibold text-slate-600">
+{task.assigned_members.length - 3}
</div>
)}
</div>
{/* Stats */}
<div className="flex items-center gap-3 text-slate-500">
{(task.attachment_count || 0) > 0 && (
<div className="flex items-center gap-1 text-xs">
<Paperclip className="w-3.5 h-3.5" />
<span>{task.attachment_count}</span>
</div>
)}
{(task.comment_count || 0) > 0 && (
<div className="flex items-center gap-1 text-xs">
<MessageSquare className="w-3.5 h-3.5" />
<span>{task.comment_count}</span>
</div>
)}
</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import { Plus, MoreVertical } from "lucide-react";
import { Droppable } from "@hello-pangea/dnd";
const columnConfig = {
pending: { bg: "bg-blue-500", label: "Pending" },
in_progress: { bg: "bg-amber-500", label: "In Progress" },
on_hold: { bg: "bg-teal-500", label: "On Hold" },
completed: { bg: "bg-green-500", label: "Completed" }
};
export default function TaskColumn({ status, tasks, children, onAddTask }) {
const config = columnConfig[status] || columnConfig.pending;
return (
<div className="flex-1 min-w-[320px]">
{/* Column Header */}
<div className={`${config.bg} text-white rounded-lg px-4 py-3 mb-4 flex items-center justify-between`}>
<div className="flex items-center gap-2">
<span className="font-bold">{config.label}</span>
<Badge className="bg-white/20 text-white border-0 font-bold">
{tasks.length}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onAddTask(status)}
className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors"
>
<Plus className="w-4 h-4" />
</button>
<button className="w-6 h-6 hover:bg-white/20 rounded flex items-center justify-center transition-colors">
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
{/* Droppable Area */}
<Droppable droppableId={status}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`min-h-[500px] rounded-lg p-3 transition-colors ${
snapshot.isDraggingOver ? 'bg-blue-50 border-2 border-dashed border-blue-300' : 'bg-slate-50/50'
}`}
>
{children}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
}

View File

@@ -0,0 +1,526 @@
import React, { useState, useRef, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Calendar, Paperclip, Send, Upload, FileText, Download, AtSign, Smile, Plus, Home, Activity, Mail, Clock, Zap, PauseCircle, CheckCircle } from "lucide-react";
import { format } from "date-fns";
import { useToast } from "@/components/ui/use-toast";
const priorityConfig = {
high: { bg: "bg-blue-100", text: "text-blue-700", label: "High" },
normal: { bg: "bg-teal-100", text: "text-teal-700", label: "Normal" },
low: { bg: "bg-amber-100", text: "text-amber-700", label: "Low" }
};
export default function TaskDetailModal({ task, open, onClose }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [comment, setComment] = useState("");
const [uploading, setUploading] = useState(false);
const [status, setStatus] = useState(task?.status || "pending");
const [activeTab, setActiveTab] = useState("updates");
const [emailNotification, setEmailNotification] = useState(false);
const fileInputRef = useRef(null);
// Auto-calculate progress based on activity
const calculateProgress = () => {
if (!task) return 0;
let progressScore = 0;
// Status contributes to progress
if (task.status === "completed") return 100;
if (task.status === "in_progress") progressScore += 40;
if (task.status === "on_hold") progressScore += 20;
// Comments/updates show activity
if (task.comment_count > 0) progressScore += Math.min(task.comment_count * 5, 20);
// Files attached show work done
if (task.attachment_count > 0) progressScore += Math.min(task.attachment_count * 10, 20);
// Assigned members
if (task.assigned_members?.length > 0) progressScore += 20;
return Math.min(progressScore, 100);
};
const currentProgress = calculateProgress();
useEffect(() => {
if (task && currentProgress !== task.progress) {
updateTaskMutation.mutate({
id: task.id,
data: { ...task, progress: currentProgress }
});
}
}, [currentProgress]);
const { data: user } = useQuery({
queryKey: ['current-user-task-modal'],
queryFn: () => base44.auth.me(),
});
const { data: comments = [] } = useQuery({
queryKey: ['task-comments', task?.id],
queryFn: () => base44.entities.TaskComment.filter({ task_id: task?.id }),
enabled: !!task?.id,
initialData: [],
});
const addCommentMutation = useMutation({
mutationFn: (commentData) => base44.entities.TaskComment.create(commentData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['task-comments', task?.id] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setComment("");
toast({
title: "✅ Comment Added",
description: "Your comment has been posted",
});
},
});
const updateTaskMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Task.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast({
title: "✅ Task Updated",
description: "Changes saved successfully",
});
},
});
const handleFileUpload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const { file_url } = await base44.integrations.Core.UploadFile({ file });
const newFile = {
file_name: file.name,
file_url: file_url,
file_size: file.size,
uploaded_by: user?.full_name || user?.email || "User",
uploaded_at: new Date().toISOString(),
};
const updatedFiles = [...(task.files || []), newFile];
await updateTaskMutation.mutateAsync({
id: task.id,
data: {
...task,
files: updatedFiles,
attachment_count: updatedFiles.length,
}
});
// Add system comment
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: user?.full_name || user?.email || "User",
author_avatar: user?.profile_picture,
comment: `Uploaded file: ${file.name}`,
is_system: true,
});
toast({
title: "✅ File Uploaded",
description: `${file.name} added successfully`,
});
} catch (error) {
toast({
title: "❌ Upload Failed",
description: error.message,
variant: "destructive",
});
} finally {
setUploading(false);
}
};
const handleStatusChange = async (newStatus) => {
setStatus(newStatus);
await updateTaskMutation.mutateAsync({
id: task.id,
data: { ...task, status: newStatus }
});
// Add system comment
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: "System",
author_avatar: "",
comment: `Status changed to ${newStatus.replace('_', ' ')}`,
is_system: true,
});
};
const handleAddComment = async () => {
if (!comment.trim()) return;
await addCommentMutation.mutateAsync({
task_id: task.id,
author_id: user?.id,
author_name: user?.full_name || user?.email || "User",
author_avatar: user?.profile_picture,
comment: comment.trim(),
is_system: false,
});
// Update comment count
await updateTaskMutation.mutateAsync({
id: task.id,
data: {
...task,
comment_count: (task.comment_count || 0) + 1,
}
});
// Send email notifications if enabled
if (emailNotification && task.assigned_members) {
for (const member of task.assigned_members) {
try {
await base44.integrations.Core.SendEmail({
to: member.member_email || `${member.member_name}@example.com`,
subject: `New update on task: ${task.task_name}`,
body: `${user?.full_name || "A team member"} posted an update:\n\n"${comment.trim()}"\n\nView task details in the app.`
});
} catch (error) {
console.error("Failed to send email:", error);
}
}
toast({
title: "✅ Update Sent",
description: "Email notifications sent to team members",
});
}
};
const handleMention = () => {
const textarea = document.querySelector('textarea');
if (textarea) {
const cursorPos = textarea.selectionStart;
const textBefore = comment.substring(0, cursorPos);
const textAfter = comment.substring(cursorPos);
setComment(textBefore + '@' + textAfter);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(cursorPos + 1, cursorPos + 1);
}, 0);
}
};
const handleEmoji = () => {
const emojis = ['👍', '❤️', '😊', '🎉', '✅', '🔥', '💪', '🚀'];
const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)];
setComment(comment + randomEmoji);
};
if (!task) return null;
const priority = priorityConfig[task.priority] || priorityConfig.normal;
const sortedComments = [...comments].sort((a, b) =>
new Date(a.created_date) - new Date(b.created_date)
);
const getProgressColor = () => {
if (currentProgress === 100) return "bg-green-500";
if (currentProgress >= 70) return "bg-blue-500";
if (currentProgress >= 40) return "bg-amber-500";
return "bg-slate-400";
};
const statusOptions = [
{ value: "pending", label: "Pending", icon: Clock, color: "bg-slate-100 text-slate-700 border-slate-300" },
{ value: "in_progress", label: "In Progress", icon: Zap, color: "bg-blue-100 text-blue-700 border-blue-300" },
{ value: "on_hold", label: "On Hold", icon: PauseCircle, color: "bg-orange-100 text-orange-700 border-orange-300" },
{ value: "completed", label: "Completed", icon: CheckCircle, color: "bg-green-100 text-green-700 border-green-300" },
];
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col p-0">
{/* Header with Task Info */}
<div className="p-6 pb-4 border-b">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h2 className="text-2xl font-bold text-slate-900 mb-2">{task.task_name}</h2>
<div className="flex items-center gap-3 flex-wrap">
<Badge className={`${priority.bg} ${priority.text} text-xs font-semibold px-3 py-1`}>
{priority.label} Priority
</Badge>
{task.due_date && (
<div className="flex items-center gap-1.5 text-sm text-slate-600 bg-slate-100 px-3 py-1 rounded-full">
<Calendar className="w-3.5 h-3.5" />
{format(new Date(task.due_date), 'MMM d, yyyy')}
</div>
)}
<div className="flex items-center gap-2">
<div className={`h-2 w-24 bg-slate-200 rounded-full overflow-hidden`}>
<div className={`h-full ${getProgressColor()} transition-all duration-500`} style={{ width: `${currentProgress}%` }}></div>
</div>
<span className="text-sm font-semibold text-slate-700">{currentProgress}%</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => {
const IconComponent = option.icon;
return (
<button
key={option.value}
onClick={() => handleStatusChange(option.value)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 font-semibold text-sm transition-all ${
status === option.value
? `${option.color} shadow-md scale-105`
: "bg-white text-slate-400 border-slate-200 hover:border-slate-300 hover:text-slate-600"
}`}
>
<IconComponent className="w-4 h-4" />
{option.label}
</button>
);
})}
</div>
</div>
{/* Assigned Members */}
{task.assigned_members && task.assigned_members.length > 0 && (
<div className="flex items-center gap-3">
<span className="text-xs font-semibold text-slate-500">ASSIGNED:</span>
<div className="flex -space-x-2">
{task.assigned_members.slice(0, 5).map((member, idx) => (
<Avatar key={idx} className="w-8 h-8 border-2 border-white">
<img
src={member.avatar_url || `https://i.pravatar.cc/150?u=${encodeURIComponent(member.member_name)}`}
alt={member.member_name}
title={member.member_name}
className="w-full h-full object-cover"
/>
</Avatar>
))}
{task.assigned_members.length > 5 && (
<div className="w-8 h-8 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-xs font-bold text-slate-600">
+{task.assigned_members.length - 5}
</div>
)}
</div>
</div>
)}
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-6 h-12">
<TabsTrigger value="updates" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Home className="w-4 h-4" />
Updates
</TabsTrigger>
<TabsTrigger value="files" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Paperclip className="w-4 h-4" />
Files ({task.files?.length || 0})
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2 data-[state=active]:border-b-2 data-[state=active]:border-[#0A39DF] rounded-none">
<Activity className="w-4 h-4" />
Activity Log
</TabsTrigger>
</TabsList>
{/* Updates Tab */}
<TabsContent value="updates" className="flex-1 overflow-y-auto m-0 p-6 space-y-4">
<div className="bg-white border-2 border-slate-200 rounded-xl overflow-hidden">
<div className="p-4 border-b bg-slate-50 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-600">Write an update</span>
<Button
variant="ghost"
size="sm"
onClick={() => setEmailNotification(!emailNotification)}
className={emailNotification ? "text-[#0A39DF] bg-blue-50" : "text-slate-500 hover:text-[#0A39DF]"}
>
<Mail className="w-4 h-4 mr-2" />
{emailNotification ? "Email enabled ✓" : "Update via email"}
</Button>
</div>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Write an update and mention others with @"
rows={4}
className="border-0 resize-none focus-visible:ring-0 text-base"
/>
<div className="p-3 bg-slate-50 flex items-center justify-between border-t">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleMention}
className="text-slate-500 hover:text-[#0A39DF]"
title="Mention someone"
>
<AtSign className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-slate-500 hover:text-[#0A39DF]"
title="Attach file"
>
<Paperclip className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleEmoji}
className="text-slate-500 hover:text-[#0A39DF]"
title="Add emoji"
>
<Smile className="w-4 h-4" />
</Button>
<input ref={fileInputRef} type="file" onChange={handleFileUpload} className="hidden" />
</div>
<Button
onClick={handleAddComment}
disabled={!comment.trim() || addCommentMutation.isPending}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Send className="w-4 h-4 mr-2" />
{addCommentMutation.isPending ? "Posting..." : "Post Update"}
</Button>
</div>
</div>
{/* Comments Feed */}
<div className="space-y-3">
{sortedComments.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Home className="w-16 h-16 mx-auto mb-3 opacity-20" />
<p className="text-sm">No updates yet. Be the first to post!</p>
</div>
) : (
sortedComments.map((commentItem) => (
<div key={commentItem.id} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex gap-3">
<Avatar className="w-10 h-10 flex-shrink-0">
<img
src={commentItem.author_avatar || `https://i.pravatar.cc/150?u=${encodeURIComponent(commentItem.author_name)}`}
alt={commentItem.author_name}
className="w-full h-full object-cover"
/>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-slate-900">{commentItem.author_name}</span>
{commentItem.is_system && (
<Badge variant="outline" className="text-xs">System</Badge>
)}
<span className="text-xs text-slate-400"></span>
<span className="text-xs text-slate-500">
{format(new Date(commentItem.created_date), 'MMM d, h:mm a')}
</span>
</div>
<p className="text-slate-700 leading-relaxed">{commentItem.comment}</p>
</div>
</div>
</div>
))
)}
</div>
</TabsContent>
{/* Files Tab */}
<TabsContent value="files" className="flex-1 overflow-y-auto m-0 p-6">
<div className="mb-4">
<Button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="bg-[#0A39DF] hover:bg-blue-700"
>
<Upload className="w-4 h-4 mr-2" />
{uploading ? "Uploading..." : "Upload File"}
</Button>
</div>
{task.files && task.files.length > 0 ? (
<div className="grid grid-cols-1 gap-3">
{task.files.map((file, idx) => (
<div key={idx} className="flex items-center justify-between p-4 bg-white border-2 border-slate-200 rounded-xl hover:shadow-md transition-shadow">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-6 h-6 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 truncate">{file.file_name}</p>
<div className="flex items-center gap-2 text-xs text-slate-500 mt-1">
<span>{(file.file_size / 1024).toFixed(1)} KB</span>
<span></span>
<span>{file.uploaded_by}</span>
<span></span>
<span>{format(new Date(file.uploaded_at), 'MMM d, h:mm a')}</span>
</div>
</div>
</div>
<a href={file.file_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</a>
</div>
))}
</div>
) : (
<div className="text-center py-16 border-2 border-dashed border-slate-200 rounded-xl">
<Paperclip className="w-16 h-16 mx-auto mb-3 text-slate-300" />
<p className="text-slate-500 font-medium mb-2">No files attached yet</p>
<p className="text-sm text-slate-400">Upload files to share with your team</p>
</div>
)}
</TabsContent>
{/* Activity Log Tab */}
<TabsContent value="activity" className="flex-1 overflow-y-auto m-0 p-6">
<div className="space-y-2">
{sortedComments.filter(c => c.is_system).length === 0 ? (
<div className="text-center py-16 text-slate-400">
<Activity className="w-16 h-16 mx-auto mb-3 opacity-20" />
<p className="text-sm">No activity logged yet</p>
</div>
) : (
<div className="relative">
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-slate-200"></div>
{sortedComments.filter(c => c.is_system).map((activity) => (
<div key={activity.id} className="relative pl-10 pb-6">
<div className="absolute left-2.5 w-3 h-3 bg-[#0A39DF] rounded-full border-2 border-white"></div>
<div className="bg-white border border-slate-200 rounded-lg p-3">
<p className="text-sm text-slate-700">{activity.comment}</p>
<p className="text-xs text-slate-400 mt-1">
{format(new Date(activity.created_date), 'MMM d, yyyy • h:mm a')}
</p>
</div>
</div>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}>
{children}
<ChevronDown
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

Some files were not shown because too many files have changed in this diff Show More