Merge pull request #173 from Oloodi/151-web-stable-dataconnect-integration-layer-minimizing-frontend-changes-on-future-regenerations
151 web stable dataconnect integration layer minimizing frontend changes on future regenerations
This commit is contained in:
@@ -2,7 +2,7 @@ mutation CreateActivityLog(
|
||||
$title: String!,
|
||||
$description: String!,
|
||||
$activityType: ActivityType!,
|
||||
$userId: UUID!,
|
||||
$userId: String!,
|
||||
$isRead: Boolean
|
||||
) @auth(level: USER) {
|
||||
activityLog_insert(
|
||||
@@ -21,7 +21,7 @@ mutation UpdateActivityLog(
|
||||
$title: String,
|
||||
$description: String,
|
||||
$activityType: ActivityType,
|
||||
$userId: UUID,
|
||||
$userId: String,
|
||||
$isRead: Boolean
|
||||
) @auth(level: USER) {
|
||||
activityLog_update(
|
||||
|
||||
@@ -23,7 +23,7 @@ query getActivityLogById(
|
||||
}
|
||||
|
||||
query filterActivityLog(
|
||||
$userId: UUID,
|
||||
$userId: String,
|
||||
$activityType: ActivityType,
|
||||
$isRead: Boolean
|
||||
) @auth(level: USER) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,16 +6,16 @@ mutation CreateEvent(
|
||||
$recurrenceType: RecurrenceType,
|
||||
$recurrenceStartDate: Timestamp,
|
||||
$recurrenceEndDate: Timestamp,
|
||||
$scatterDates: String,
|
||||
$scatterDates: Any,
|
||||
$multiDayStartDate: Timestamp,
|
||||
$multiDayEndDate: Timestamp,
|
||||
$bufferTimeBefore: Float,
|
||||
$bufferTimeAfter: Float,
|
||||
$conflictDetectionEnabled: Boolean,
|
||||
$detectedConflicts: String,
|
||||
$detectedConflicts: Any,
|
||||
$businessId: UUID!,
|
||||
$businessName: String,
|
||||
$vendorId: UUID,
|
||||
$vendorId: String,
|
||||
$vendorName: String,
|
||||
$hub: String,
|
||||
$eventLocation: String,
|
||||
@@ -23,8 +23,8 @@ mutation CreateEvent(
|
||||
$poReference: String,
|
||||
$status: EventStatus!,
|
||||
$date: Timestamp!,
|
||||
$shifts: String,
|
||||
$addons: String,
|
||||
$shifts: Any,
|
||||
$addons: Any,
|
||||
$total: Float,
|
||||
$clientName: String,
|
||||
$clientEmail: String,
|
||||
@@ -32,7 +32,7 @@ mutation CreateEvent(
|
||||
$invoiceId: UUID,
|
||||
$notes: String,
|
||||
$requested: Int,
|
||||
$assignedStaff: String
|
||||
$assignedStaff: Any
|
||||
) @auth(level: USER) {
|
||||
event_insert(
|
||||
data: {
|
||||
@@ -84,16 +84,16 @@ mutation UpdateEvent(
|
||||
$recurrenceType: RecurrenceType,
|
||||
$recurrenceStartDate: Timestamp,
|
||||
$recurrenceEndDate: Timestamp,
|
||||
$scatterDates: String,
|
||||
$scatterDates: Any,
|
||||
$multiDayStartDate: Timestamp,
|
||||
$multiDayEndDate: Timestamp,
|
||||
$bufferTimeBefore: Float,
|
||||
$bufferTimeAfter: Float,
|
||||
$conflictDetectionEnabled: Boolean,
|
||||
$detectedConflicts: String,
|
||||
$detectedConflicts: Any,
|
||||
$businessId: UUID,
|
||||
$businessName: String,
|
||||
$vendorId: UUID,
|
||||
$vendorId: String,
|
||||
$vendorName: String,
|
||||
$hub: String,
|
||||
$eventLocation: String,
|
||||
@@ -101,8 +101,8 @@ mutation UpdateEvent(
|
||||
$poReference: String,
|
||||
$status: EventStatus,
|
||||
$date: Timestamp,
|
||||
$shifts: String,
|
||||
$addons: String,
|
||||
$shifts: Any,
|
||||
$addons: Any,
|
||||
$total: Float,
|
||||
$clientName: String,
|
||||
$clientEmail: String,
|
||||
@@ -110,7 +110,7 @@ mutation UpdateEvent(
|
||||
$invoiceId: UUID,
|
||||
$notes: String,
|
||||
$requested: Int,
|
||||
$assignedStaff: String
|
||||
$assignedStaff: Any
|
||||
) @auth(level: USER) {
|
||||
event_update(
|
||||
id: $id,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
query listEvents @auth(level: USER) {
|
||||
events {
|
||||
query listEvents (
|
||||
$orderByDate: OrderDirection
|
||||
$limit: Int
|
||||
) @auth(level: USER) {
|
||||
events(
|
||||
orderBy: { date: $orderByDate }
|
||||
limit: $limit
|
||||
) {
|
||||
id
|
||||
eventName
|
||||
status
|
||||
@@ -84,7 +90,7 @@ query getEventById(
|
||||
query filterEvents(
|
||||
$status: EventStatus,
|
||||
$businessId: UUID,
|
||||
$vendorId: UUID,
|
||||
$vendorId: String,
|
||||
$isRecurring: Boolean,
|
||||
$isRapid: Boolean,
|
||||
$isMultiDay: Boolean,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mutation CreateTeam(
|
||||
$teamName: String!,
|
||||
$ownerId: UUID!,
|
||||
$ownerId: String!,
|
||||
$ownerName: String!,
|
||||
$ownerRole: TeamOwnerRole!,
|
||||
$favoriteStaff: String,
|
||||
@@ -21,7 +21,7 @@ mutation CreateTeam(
|
||||
mutation UpdateTeam(
|
||||
$id: UUID!,
|
||||
$teamName: String,
|
||||
$ownerId: UUID,
|
||||
$ownerId: String,
|
||||
$ownerName: String,
|
||||
$ownerRole: TeamOwnerRole,
|
||||
$favoriteStaff: String,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
query listTeam @auth(level: USER) {
|
||||
teams {
|
||||
query listTeam (
|
||||
$orderByCreatedDate: OrderDirection
|
||||
$limit: Int
|
||||
) @auth(level: USER) {
|
||||
teams(
|
||||
orderBy: { createdDate: $orderByCreatedDate }
|
||||
limit: $limit
|
||||
) {
|
||||
id
|
||||
teamName
|
||||
ownerId
|
||||
@@ -26,7 +32,7 @@ query getTeamById(
|
||||
|
||||
query filterTeam(
|
||||
$teamName: String,
|
||||
$ownerId: UUID,
|
||||
$ownerId: String,
|
||||
$ownerRole: TeamOwnerRole
|
||||
) @auth(level: USER) {
|
||||
teams(
|
||||
|
||||
@@ -13,7 +13,7 @@ type ActivityLog @table(name: "activity_logs") {
|
||||
title: String!
|
||||
description: String!
|
||||
activityType: ActivityType!
|
||||
userId: UUID! # user_id (FK lógica a User.id)
|
||||
userId: String! # user_id (FK lógica a User.id)
|
||||
isRead: Boolean @default(expr: "false")
|
||||
createdDate: Timestamp @default(expr: "request.time")
|
||||
updatedDate: Timestamp @default(expr: "request.time")
|
||||
|
||||
@@ -33,16 +33,16 @@ type Event @table(name: "events") {
|
||||
recurrenceType: RecurrenceType
|
||||
recurrenceStartDate: Timestamp
|
||||
recurrenceEndDate: Timestamp
|
||||
scatterDates: String
|
||||
scatterDates: Any
|
||||
multiDayStartDate: Timestamp
|
||||
multiDayEndDate: Timestamp
|
||||
bufferTimeBefore: Float @default(expr: "0")
|
||||
bufferTimeAfter: Float @default(expr: "0")
|
||||
conflictDetectionEnabled: Boolean @default(expr: "true")
|
||||
detectedConflicts: String
|
||||
detectedConflicts: Any
|
||||
businessId: UUID!
|
||||
businessName: String
|
||||
vendorId: UUID
|
||||
vendorId: String
|
||||
vendorName: String
|
||||
hub: String
|
||||
eventLocation: String
|
||||
@@ -50,8 +50,8 @@ type Event @table(name: "events") {
|
||||
poReference: String
|
||||
status: EventStatus!
|
||||
date: Timestamp!
|
||||
shifts: String
|
||||
addons: String
|
||||
shifts: Any
|
||||
addons: Any
|
||||
total: Float
|
||||
clientName: String
|
||||
clientEmail: String
|
||||
@@ -59,7 +59,7 @@ type Event @table(name: "events") {
|
||||
invoiceId: UUID
|
||||
notes: String
|
||||
requested: Int @default(expr: "0")
|
||||
assignedStaff: String
|
||||
assignedStaff: Any
|
||||
createdDate: Timestamp @default(expr: "request.time")
|
||||
updatedDate: Timestamp @default(expr: "request.time")
|
||||
createdBy: String @default(expr: "auth.uid")
|
||||
|
||||
@@ -11,7 +11,7 @@ enum TeamOwnerRole {
|
||||
type Team @table(name: "team") {
|
||||
id: UUID! @default(expr: "uuidV4()")
|
||||
teamName: String!
|
||||
ownerId: UUID!
|
||||
ownerId: String!
|
||||
ownerName: String!
|
||||
ownerRole: TeamOwnerRole!
|
||||
favoriteStaff: String
|
||||
|
||||
26
frontend-web-free/.gitignore
vendored
Normal file
26
frontend-web-free/.gitignore
vendored
Normal 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
|
||||
20
frontend-web-free/README.md
Normal file
20
frontend-web-free/README.md
Normal 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.
|
||||
21
frontend-web-free/components.json
Normal file
21
frontend-web-free/components.json
Normal 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"
|
||||
}
|
||||
38
frontend-web-free/eslint.config.js
Normal file
38
frontend-web-free/eslint.config.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
frontend-web-free/index.html
Normal file
13
frontend-web-free/index.html
Normal 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>
|
||||
10
frontend-web-free/jsconfig.json
Normal file
10
frontend-web-free/jsconfig.json
Normal 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
9985
frontend-web-free/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend-web-free/package.json
Normal file
82
frontend-web-free/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend-web-free/postcss.config.js
Normal file
6
frontend-web-free/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
0
frontend-web-free/src/App.css
Normal file
0
frontend-web-free/src/App.css
Normal file
14
frontend-web-free/src/App.jsx
Normal file
14
frontend-web-free/src/App.jsx
Normal 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
|
||||
11
frontend-web-free/src/api/base44Client.js
Normal file
11
frontend-web-free/src/api/base44Client.js
Normal 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;
|
||||
22
frontend-web-free/src/api/client.js
Normal file
22
frontend-web-free/src/api/client.js
Normal 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;
|
||||
81
frontend-web-free/src/api/entities.js
Normal file
81
frontend-web-free/src/api/entities.js
Normal 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;
|
||||
26
frontend-web-free/src/api/integrations.js
Normal file
26
frontend-web-free/src/api/integrations.js
Normal 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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
675
frontend-web-free/src/api/krowSDK.js
Normal file
675
frontend-web-free/src/api/krowSDK.js
Normal 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,
|
||||
};
|
||||
25
frontend-web-free/src/components/auth/ProtectedRoute.jsx
Normal file
25
frontend-web-free/src/components/auth/ProtectedRoute.jsx
Normal 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;
|
||||
}
|
||||
25
frontend-web-free/src/components/auth/PublicRoute.jsx
Normal file
25
frontend-web-free/src/components/auth/PublicRoute.jsx
Normal 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;
|
||||
}
|
||||
273
frontend-web-free/src/components/business/BusinessCard.jsx
Normal file
273
frontend-web-free/src/components/business/BusinessCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
256
frontend-web-free/src/components/business/ERPSettingsTab.jsx
Normal file
256
frontend-web-free/src/components/business/ERPSettingsTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
frontend-web-free/src/components/chat/ChatBubble.jsx
Normal file
321
frontend-web-free/src/components/chat/ChatBubble.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
frontend-web-free/src/components/common/DragDropFileUpload.jsx
Normal file
128
frontend-web-free/src/components/common/DragDropFileUpload.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
46
frontend-web-free/src/components/common/PageHeader.jsx
Normal file
46
frontend-web-free/src/components/common/PageHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
316
frontend-web-free/src/components/dashboard/EcosystemWheel.jsx
Normal file
316
frontend-web-free/src/components/dashboard/EcosystemWheel.jsx
Normal 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>);
|
||||
|
||||
}
|
||||
43
frontend-web-free/src/components/dashboard/QuickMetrics.jsx
Normal file
43
frontend-web-free/src/components/dashboard/QuickMetrics.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
frontend-web-free/src/components/dev/RoleSwitcher.jsx
Normal file
224
frontend-web-free/src/components/dev/RoleSwitcher.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
632
frontend-web-free/src/components/events/AIOrderAssistant.jsx
Normal file
632
frontend-web-free/src/components/events/AIOrderAssistant.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
frontend-web-free/src/components/events/AssignedStaffManager.jsx
Normal file
244
frontend-web-free/src/components/events/AssignedStaffManager.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
837
frontend-web-free/src/components/events/EventAssignmentModal.jsx
Normal file
837
frontend-web-free/src/components/events/EventAssignmentModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1102
frontend-web-free/src/components/events/EventForm.jsx
Normal file
1102
frontend-web-free/src/components/events/EventForm.jsx
Normal file
File diff suppressed because it is too large
Load Diff
1248
frontend-web-free/src/components/events/EventFormWizard.jsx
Normal file
1248
frontend-web-free/src/components/events/EventFormWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
134
frontend-web-free/src/components/events/EventHoverCard.jsx
Normal file
134
frontend-web-free/src/components/events/EventHoverCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend-web-free/src/components/events/EventsTable.jsx
Normal file
136
frontend-web-free/src/components/events/EventsTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
frontend-web-free/src/components/events/QuickAssignPopover.jsx
Normal file
210
frontend-web-free/src/components/events/QuickAssignPopover.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
frontend-web-free/src/components/events/QuickReorderModal.jsx
Normal file
249
frontend-web-free/src/components/events/QuickReorderModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
frontend-web-free/src/components/events/ShiftCard.jsx
Normal file
162
frontend-web-free/src/components/events/ShiftCard.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
426
frontend-web-free/src/components/events/ShiftRoleCard.jsx
Normal file
426
frontend-web-free/src/components/events/ShiftRoleCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
frontend-web-free/src/components/events/ShiftRolesTable.jsx
Normal file
323
frontend-web-free/src/components/events/ShiftRolesTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
frontend-web-free/src/components/events/ShiftSection.jsx
Normal file
320
frontend-web-free/src/components/events/ShiftSection.jsx
Normal 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);
|
||||
}
|
||||
611
frontend-web-free/src/components/events/SmartAssignModal.jsx
Normal file
611
frontend-web-free/src/components/events/SmartAssignModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
687
frontend-web-free/src/components/events/StaffAssignment.jsx
Normal file
687
frontend-web-free/src/components/events/StaffAssignment.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend-web-free/src/components/events/StatusCard.jsx
Normal file
29
frontend-web-free/src/components/events/StatusCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
frontend-web-free/src/components/events/VendorRoutingPanel.jsx
Normal file
340
frontend-web-free/src/components/events/VendorRoutingPanel.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
200
frontend-web-free/src/components/invoices/CreateInvoiceModal.jsx
Normal file
200
frontend-web-free/src/components/invoices/CreateInvoiceModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
444
frontend-web-free/src/components/invoices/InvoiceDetailModal.jsx
Normal file
444
frontend-web-free/src/components/invoices/InvoiceDetailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
447
frontend-web-free/src/components/invoices/InvoiceDetailView.jsx
Normal file
447
frontend-web-free/src/components/invoices/InvoiceDetailView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
frontend-web-free/src/components/invoices/InvoiceExportPanel.jsx
Normal file
316
frontend-web-free/src/components/invoices/InvoiceExportPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend-web-free/src/components/messaging/ConversationList.jsx
Normal file
108
frontend-web-free/src/components/messaging/ConversationList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend-web-free/src/components/messaging/MessageInput.jsx
Normal file
70
frontend-web-free/src/components/messaging/MessageInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
frontend-web-free/src/components/messaging/MessageThread.jsx
Normal file
99
frontend-web-free/src/components/messaging/MessageThread.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
141
frontend-web-free/src/components/onboarding/CompletionStep.jsx
Normal file
141
frontend-web-free/src/components/onboarding/CompletionStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
193
frontend-web-free/src/components/onboarding/ProfileSetupStep.jsx
Normal file
193
frontend-web-free/src/components/onboarding/ProfileSetupStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend-web-free/src/components/onboarding/TrainingStep.jsx
Normal file
173
frontend-web-free/src/components/onboarding/TrainingStep.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
frontend-web-free/src/components/orders/CancellationFeeModal.jsx
Normal file
161
frontend-web-free/src/components/orders/CancellationFeeModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
362
frontend-web-free/src/components/orders/OrderDetailModal.jsx
Normal file
362
frontend-web-free/src/components/orders/OrderDetailModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend-web-free/src/components/orders/OrderReductionAlert.jsx
Normal file
109
frontend-web-free/src/components/orders/OrderReductionAlert.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend-web-free/src/components/orders/OrderStatusBadge.jsx
Normal file
149
frontend-web-free/src/components/orders/OrderStatusBadge.jsx
Normal 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);
|
||||
});
|
||||
}
|
||||
37
frontend-web-free/src/components/orders/OrderStatusUtils.jsx
Normal file
37
frontend-web-free/src/components/orders/OrderStatusUtils.jsx
Normal 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";
|
||||
}
|
||||
332
frontend-web-free/src/components/orders/RapidOrderChat.jsx
Normal file
332
frontend-web-free/src/components/orders/RapidOrderChat.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
frontend-web-free/src/components/orders/RapidOrderInterface.jsx
Normal file
276
frontend-web-free/src/components/orders/RapidOrderInterface.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
374
frontend-web-free/src/components/orders/SmartAssignModal.jsx
Normal file
374
frontend-web-free/src/components/orders/SmartAssignModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
192
frontend-web-free/src/components/procurement/COIViewer.jsx
Normal file
192
frontend-web-free/src/components/procurement/COIViewer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
133
frontend-web-free/src/components/procurement/VendorHoverCard.jsx
Normal file
133
frontend-web-free/src/components/procurement/VendorHoverCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
346
frontend-web-free/src/components/procurement/W9FormViewer.jsx
Normal file
346
frontend-web-free/src/components/procurement/W9FormViewer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
frontend-web-free/src/components/rates/RateCardModal.jsx
Normal file
217
frontend-web-free/src/components/rates/RateCardModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
frontend-web-free/src/components/reports/ClientTrendsReport.jsx
Normal file
202
frontend-web-free/src/components/reports/ClientTrendsReport.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
frontend-web-free/src/components/reports/CustomReportBuilder.jsx
Normal file
333
frontend-web-free/src/components/reports/CustomReportBuilder.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
234
frontend-web-free/src/components/reports/StaffingCostReport.jsx
Normal file
234
frontend-web-free/src/components/reports/StaffingCostReport.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend-web-free/src/components/scheduling/AutomationEngine.jsx
Normal file
211
frontend-web-free/src/components/scheduling/AutomationEngine.jsx
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: '' };
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
218
frontend-web-free/src/components/scheduling/TalentRadar.jsx
Normal file
218
frontend-web-free/src/components/scheduling/TalentRadar.jsx
Normal 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));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user