feat: Initial commit of KROW Workforce Web client (Base44 export)

This commit is contained in:
bwnyasse
2025-11-11 06:08:01 -05:00
commit e571193362
173 changed files with 50898 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Vite
.vite/
.temp/
# Local Secrets
.env
.env.local
.env.*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build output
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
# KROW Workforce Project Makefile
# -------------------------------
# This Makefile provides a central place for common project commands.
# It is designed to be the main entry point for developers.
# Use .PHONY to declare targets that are not files, to avoid conflicts.
.PHONY: install dev build prepare-export help
# The default command to run if no target is specified (e.g., just 'make').
.DEFAULT_GOAL := help
# Installs all project dependencies using npm.
install:
@echo "--> Installing project dependencies..."
@npm install
# Starts the local development server.
dev:
@echo "--> Starting development server on http://localhost:5173 ..."
@npm run dev
# Builds the application for production.
build:
@echo "--> Building application for production..."
@npm run build
# Applies all necessary patches to a fresh Base44 export to run it locally.
# This is the main command for the hybrid workflow.
prepare-export:
@echo "--> Preparing fresh Base44 export for local development..."
@node scripts/prepare-export.js
@echo "--> Preparation complete. You can now run 'make dev'."
# Shows this help message.
help:
@echo "--------------------------------------------------"
@echo " KROW Workforce - Available Makefile Commands"
@echo "--------------------------------------------------"
@echo " make install - Installs project dependencies."
@echo " make dev - Starts the local development server."
@echo " make build - Builds the application for production."
@echo " make prepare-export - Prepares a fresh Base44 export for local use."
@echo " make help - Shows this help message."
@echo "--------------------------------------------------"

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# KROW Workforce - Frontend
Ce projet contient le code du frontend pour la plateforme KROW Workforce. Il a été initialement prototypé sur la plateforme low-code Base44 et est en cours de migration vers une infrastructure backend personnalisée sur Google Cloud Platform (GCP).
Ce `README.md` est le guide officiel pour l'équipe de développement. **NE PAS REMPLACER** ce fichier par celui fourni dans les exports de Base44.
---
## 🚀 Démarrage Rapide
Ce projet utilise un `Makefile` comme point d'entrée principal pour toutes les commandes courantes.
### Prérequis
- Node.js (version LTS recommandée)
- npm
- `make` (généralement pré-installé sur Linux et macOS)
### Installation et Lancement
1. **Installer les dépendances :**
```bash
make install
```
2. **Lancer le serveur de développement :**
```bash
make dev
```
L'application sera disponible sur `http://localhost:5173`.
---
## Makefile - Commandes Disponibles
Utilisez `make help` pour voir toutes les commandes disponibles.
- `make install`: Installe les dépendances du projet.
- `make dev`: Lance le serveur de développement en local.
- `make build`: Construit l'application pour la production.
- `make prepare-export`: **(Workflow Hybride)** Applique les patchs nécessaires à un nouvel export de Base44.
---
## 🔄 Workflow d'Intégration des Mises à Jour de Base44
Pour intégrer les nouvelles modifications de l'UI faites par la cliente sur la plateforme Base44, suivez ce processus rigoureux :
### 1. Valider les Changements d'API
Avant de toucher au code, demandez au chat de Base44 la dernière documentation de l'API et des schémas. Comparez-la avec notre fichier `docs/api_specification.md` pour identifier tout changement qui impacterait le backend.
### 2. Intégrer le Nouveau Frontend
1. **Créez une branche dédiée** dans Git :
```bash
git checkout -b integration/base44-update-YYYY-MM-DD
```
2. Dans le nouvel export de Base44, **copiez le contenu du dossier `src`** et remplacez le dossier `src` de ce projet.
3. **Exécutez le script de préparation** pour neutraliser le SDK Base44 et appliquer nos patchs :
```bash
make prepare-export
```
4. **Analysez les différences** avec `git diff`. Intégrez les nouveaux composants et les modifications de l'UI, mais **rejetez les changements** sur les fichiers que nous avons patchés (`src/api/base44Client.js`, `src/main.jsx`, `src/pages/Layout.jsx`) pour conserver notre environnement local fonctionnel.
5. **Testez l'application** en local avec `make dev` pour vous assurer que tout fonctionne comme prévu.
6. Commitez vos changements.
---
## 📂 Structure du Projet
- `scripts/prepare-export.js`: Script de patching pour le workflow hybride.
- `docs/`: Contient la documentation du projet (spécification de l'API, guides...).
- `src/`: Code source de l'application.
- `src/api/`: Contient la configuration du client API (actuellement mocké).
- `src/components/`: Composants React réutilisables.
- `src/pages/`: Vues principales de l'application, correspondant aux routes.
- `src/lib/`: Utilitaires et bibliothèques partagées.
- `Makefile`: Orchestrateur des commandes du projet.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

254
docs/API_documentation.md Normal file
View File

@@ -0,0 +1,254 @@
Voici le contenu reformaté en Markdown pour une meilleure lisibilité :
# KROW Workforce Control Tower - Documentation de l'API
**Version :** 1.0.0
**Dernière mise à jour :** Décembre 2024
**URL de base :** `base44.entities` et `base44.integrations`
---
## Table des matières
1. [Introduction](#introduction)
2. [Authentification](#authentication)
3. [API des Entités](#entities-api)
4. [API des Intégrations](#integrations-api)
5. [Meilleures Pratiques](#best-practices)
6. [Gestion des Erreurs](#error-handling)
7. [Exemples](#examples)
---
## Introduction
L'API KROW Workforce Control Tower fournit une plateforme complète pour la gestion des opérations de la main-d'œuvre, des événements, des fournisseurs, de la conformité, et plus encore. Construite sur la plateforme Base44, elle offre :
* **Architecture multi-locataire** supportant les entreprises, les secteurs, les partenaires, les fournisseurs et la main-d'œuvre.
* Gestion des données en **temps réel** avec l'intégration de React Query.
* **Contrôle d'accès basé sur les rôles** (Admin, Achats, Opérateur, Secteur, Client, Fournisseur, Main-d'œuvre).
* Suivi de la **conformité** et gestion des certifications.
* Gestion des événements et des commandes avec des modèles récurrents.
* Optimisation des cartes de tarifs et de la tarification.
---
## Authentification
### Utilisateur Actuel
```javascript
import { base44 } from '@/api/base44Client';
// Obtenir l'utilisateur actuellement authentifié
const user = await base44.auth.me();
// Retourne : { id, email, full_name, role, user_role, company_name, ... }
// Vérifier si l'utilisateur est authentifié
const isAuthenticated = await base44.auth.isAuthenticated();
// Retourne : boolean
// Mettre à jour le profil de l'utilisateur actuel
await base44.auth.updateMe({
full_name: "John Doe",
phone: "(555) 123-4567"
// Note : Impossible de remplacer les champs intégrés (id, email, role)
});
// Déconnexion
base44.auth.logout(); // Recharge la page
base44.auth.logout('/login'); // Redirige vers une URL spécifique
// Rediriger vers la page de connexion
base44.auth.redirectToLogin(); // Page actuelle comme nextUrl
base44.auth.redirectToLogin('/dashboard'); // nextUrl personnalisé
```
### Rôles des Utilisateurs
* **admin** - Administrateur de la plateforme avec un accès complet.
* **procurement** - Gère les fournisseurs, les partenaires et la conformité.
* **operator** - Supervise plusieurs secteurs.
* **sector** - Gère une branche/localisation spécifique.
* **client** - Demande des services de main-d'œuvre.
* **vendor** - Fournit des services de main-d'œuvre.
* **workforce** - Membres individuels du personnel.
---
## API des Entités
Toutes les entités suivent le même modèle CRUD avec des champs intégrés :
* `id` (string, généré automatiquement)
* `created_date` (timestamp ISO 8601)
* `updated_date` (timestamp ISO 8601)
* `created_by` (email du créateur)
### Opérations Générales sur les Entités
```javascript
// Lister tous les enregistrements
const records = await base44.entities.EntityName.list();
// Lister avec tri (préfixer avec - pour un tri descendant)
const sorted = await base44.entities.EntityName.list('-created_date', 50);
// Filtrer les enregistrements
const filtered = await base44.entities.EntityName.filter({
status: 'Active',
created_by: user.email
}, '-updated_date', 10);
// Créer un enregistrement
const newRecord = await base44.entities.EntityName.create({
field1: "value1",
field2: "value2"
});
// Création en masse
const records = await base44.entities.EntityName.bulkCreate([
{ field1: "value1" },
{ field1: "value2" }
]);
// Mettre à jour un enregistrement
await base44.entities.EntityName.update(recordId, {
field1: "updated_value"
});
// Supprimer un enregistrement
await base44.entities.EntityName.delete(recordId);
// Obtenir le schéma de l'entité
const schema = await base44.entities.EntityName.schema();
// Retourne le schéma JSON sans les champs intégrés
```
### Référence des Entités
#### 1. Event (Gestion des Commandes)
**Objectif :** Gérer les commandes de main-d'œuvre avec prise en charge des commandes ponctuelles, récurrentes, rapides et permanentes.
**Champs Clés :**
* `event_name` (string, optionnel)
* `order_type` (enum: `rapid`, `one_time`, `recurring`, `permanent`)
* `business_id` / `business_name` (référence client)
* `vendor_id` / `vendor_name` (référence fournisseur)
* `status` (enum: `Draft`, `Active`, `Pending`, `Assigned`, `Confirmed`, `Completed`, `Canceled`)
* `shifts` (tableau d'objets de quarts de travail avec des rôles)
#### 2. Staff (Gestion de la Main-d'œuvre)
**Objectif :** Gérer les membres individuels du personnel avec suivi des performances et de la conformité.
**Champs Clés :**
* `employee_name` (string, requis)
* `vendor_id` / `vendor_name` (fournisseur propriétaire)
* `rating` (nombre, 0-5 étoiles)
* `shift_coverage_percentage` (nombre, 0-100)
* `background_check_status` (enum: `pending`, `cleared`, `failed`, `expired`)
#### 3. Vendor
**Objectif :** Gérer les partenaires fournisseurs de services de main-d'œuvre.
**Champs Clés :**
* `vendor_number` (string, format: `VN-####`)
* `legal_name` (string, requis)
* `approval_status` (enum: `pending`, `approved`, `suspended`, `terminated`)
* `is_active` (boolean)
#### 4. VendorRate
**Objectif :** Définir la tarification des services des fournisseurs avec suivi de la conformité.
**Champs Clés :**
* `vendor_id` / `vendor_name` (référence fournisseur)
* `role_name` (string, nom du poste)
* `employee_wage` (nombre)
* `client_rate` (nombre, taux horaire final)
* `pricing_status` (enum: `optimal`, `underpriced`, `overpriced`, `competitive`)
... et ainsi de suite pour les autres entités (`Business`, `Certification`, `Enterprise`, `Sector`, `Partner`, `Order`, `Invoice`, `Team`, `ActivityLog`).
---
## API des Intégrations
### Intégrations Principales
Toutes les intégrations sont accessibles via `base44.integrations.Core.*` :
1. **InvokeLLM** : Générer des réponses IA avec un contexte web optionnel et une sortie JSON.
2. **SendEmail** : Envoyer des e-mails aux utilisateurs.
3. **UploadFile** : Télécharger des fichiers sur un stockage public.
4. **GenerateImage** : Génération d'images par IA.
5. **ExtractDataFromUploadedFile** : Extraire des données structurées de documents.
6. **Gestion de Fichiers Privés** : Pour un stockage de fichiers sécurisé.
---
## Meilleures Pratiques
1. **Utiliser React Query pour la récupération de données** : Pour la mise en cache, la refraîchissement en arrière-plan et la gestion de l'état du serveur.
2. **Gérer le formatage des dates en toute sécurité** : Éviter les erreurs avec des dates non valides.
3. **Filtrer par rôle d'utilisateur** : Appliquer une logique de filtrage côté client en fonction du rôle de l'utilisateur.
4. **Opérations par lots** : Utiliser `bulkCreate` au lieu de plusieurs appels `create`.
5. **Gestion des erreurs avec des Toasts** : Fournir un retour d'information clair à l'utilisateur en cas de succès ou d'échec.
---
## Gestion des Erreurs
### Modèles d'Erreurs Courants
* **Entité non trouvée** : Gérer les cas où un `filter` ne retourne aucun résultat.
* **Erreurs de validation** : Capturer et afficher les erreurs de validation du backend (par exemple, salaire inférieur au minimum).
* **Erreurs d'authentification** : Rediriger vers la page de connexion lorsque la session de l'utilisateur a expiré.
---
## Exemples
### Flux de Commande Complet
1. **Créer un événement** en utilisant `base44.entities.Event.create`.
2. **Assigner du personnel** en filtrant le personnel disponible et en mettant à jour l'événement.
3. **Envoyer des notifications** aux membres du personnel assignés via `ActivityLog` et `SendEmail`.
4. **Créer une facture** pour l'événement complété.
### Surveillance de la Conformité
* Surveiller les **certifications expirant bientôt** et notifier les employés.
* Vérifier la **conformité des salaires** par rapport au salaire minimum.
### Tableau de Bord Analytique
* Calculer des **statistiques sur les événements** (total, actifs, revenus).
* Identifier les **meilleurs performers** parmi le personnel en fonction de l'évaluation et de la couverture des quarts.
* Suivre la **performance des fournisseurs** actifs et approuvés.
---
## Limites de Taux & Performance
* Pas de limites de taux explicites sur la plateforme Base44.
* Utiliser la **pagination** pour les grands ensembles de données.
* Privilégier les **opérations par lots**.
* Utiliser le **filtrage côté serveur** (`filter()`) au lieu de la récupération complète.
---
## Support & Ressources
* **Documentation de la plateforme :** [Documentation Base44](https://example.com)
* **Signalement de problèmes :** Utiliser le bouton de feedback dans l'application.
* **Demandes de fonctionnalités :** Contacter l'équipe Base44.
* **Support d'urgence :** [Informations de contact]
---
## Journal des Modifications
**Version 1.0.0 (Décembre 2024)**
* Documentation initiale de l'API.
* Documentation de toutes les entités principales.
* Documentation des points de terminaison d'intégration.
* Ajout d'exemples et de meilleures pratiques.

241
docs/api_specification.md Normal file
View File

@@ -0,0 +1,241 @@
# Spécification de l'API KROW Workforce (Migration GCP)
**Version :** 1.0
**Date :** 10/11/2025
**Objectif :** Ce document définit l'API RESTful à construire sur Google Cloud Platform (Cloud Functions, Cloud SQL) pour remplacer le backend Base44. Il est basé sur la documentation fournie par Base44 et une analyse exhaustive de l'utilisation de son SDK dans le code source du frontend.
---
## Conventions Générales
- **URL de base :** `/api/v1`
- **Authentification :** Chaque requête (sauf `POST /login` et `GET /health`) doit inclure un header `Authorization: Bearer <Firebase-Auth-Token>`.
- **Format des données :** Toutes les requêtes et réponses seront au format `application/json`.
- **Réponses d'erreur :** Les erreurs utiliseront les codes de statut HTTP standards (400, 401, 403, 404, 500) et incluront un corps de réponse JSON de la forme `{ "error": "Description du problème" }`.
- **Champs Communs :** Chaque entité aura les champs suivants, gérés automatiquement par le backend :
- `id`: `string` (UUID, Clé primaire)
- `created_date`: `string` (ISO 8601 Timestamp)
- `updated_date`: `string` (ISO 8601 Timestamp)
- `created_by`: `string` (Email de l'utilisateur créateur)
---
## 1. Authentification (`/auth`)
Basé sur Firebase Authentication. Le frontend gère l'inscription et la connexion avec le SDK Firebase. Notre backend a besoin d'un endpoint pour récupérer les informations étendues de l'utilisateur.
### Entité `User`
| Champ | Type | Description |
| --------------- | -------- | ----------------------------------------- |
| `id` | `string` | ID Firebase de l'utilisateur |
| `email` | `string` | Email de l'utilisateur (non modifiable) |
| `full_name` | `string` | Nom complet |
| `user_role` | `string` | Rôle (`admin`, `procurement`, `client`...) |
| `company_name` | `string` | Nom de l'entreprise associée |
| `profile_picture` | `string` | URL de l'image de profil |
### Endpoints
#### `GET /auth/me`
- **Description :** Récupère les informations du profil de l'utilisateur actuellement authentifié.
- **Utilisation observée :** `base44.auth.me()` (utilisé dans presque toutes les pages).
- **Réponse (200 OK) :**
```json
{
"id": "firebase-user-id-123",
"email": "dev@example.com",
"full_name": "Dev User",
"user_role": "admin",
"company_name": "KROW Corp",
"profile_picture": "https://..."
}
```
#### `PUT /auth/me`
- **Description :** Met à jour le profil de l'utilisateur actuel.
- **Utilisation observée :** `base44.auth.updateMe(data)` (dans `WorkforceProfile.jsx`, `RoleSwitcher.jsx`).
- **Corps de la requête :**
```json
{
"full_name": "John Doe",
"profile_picture": "https://..."
}
```
- **Réponse (200 OK) :** L'objet `User` mis à jour.
---
## 2. Entités (CRUD)
### 2.1. Event
**Description :** Gère les commandes de main-d'œuvre.
#### Schéma `Event`
| Champ | Type | Description |
| ------------- | -------- | ------------------------------------------------------------------------ |
| `event_name` | `string` | Nom de l'événement/commande |
| `order_type` | `string` | Enum: `rapid`, `one_time`, `recurring`, `permanent` |
| `business_id` | `string` | ID de l'entité `Business` (client) |
| `vendor_id` | `string` | ID de l'entité `Vendor` (fournisseur) |
| `status` | `string` | Enum: `Draft`, `Active`, `Pending`, `Assigned`, `Confirmed`, `Completed`, `Canceled` |
| `shifts` | `array` | Tableau d'objets `Shift` (défini comme une sous-entité ou un type JSON) |
| `date` | `string` | ISO 8601 Timestamp, date de début de l'événement |
#### Endpoints
- **`POST /events`**
- **SDK :** `base44.entities.Event.create(eventData)`
- **Utilisation :** `CreateEvent.jsx`, `QuickReorderModal.jsx`
- **Body :** Objet `Event` sans les champs communs.
- **Réponse :** Le nouvel objet `Event` créé.
- **`GET /events`**
- **SDK :** `base44.entities.Event.list(sort, limit)` et `.filter(query, sort, limit)`
- **Utilisation :** `Events.jsx`, `Dashboard.jsx`, `WorkforceShifts.jsx`, etc.
- **Paramètres de requête :**
- `sort` (ex: `-date` pour trier par date décroissante)
- `limit` (ex: `50`)
- Autres champs de l'entité pour le filtrage (ex: `status=Active`)
- **Réponse :** Tableau d'objets `Event`.
- **`PUT /events/{id}`**
- **SDK :** `base44.entities.Event.update(id, data)`
- **Utilisation :** `EditEvent.jsx`, `QuickAssignPopover.jsx`
- **Body :** Un objet avec les champs de `Event` à mettre à jour.
- **Réponse :** L'objet `Event` mis à jour.
### 2.2. Staff
**Description :** Gère les membres du personnel.
#### Schéma `Staff`
| Champ | Type | Description |
| ------------------------- | -------- | ----------------------------------------- |
| `employee_name` | `string` | Requis. Nom de l'employé. |
| `vendor_id` | `string` | Fournisseur auquel l'employé est rattaché. |
| `rating` | `number` | Note de 0 à 5. |
| `shift_coverage_percentage` | `number` | Pourcentage de shifts couverts. |
| `background_check_status` | `string` | Enum: `pending`, `cleared`, `failed`, `expired` |
#### Endpoints
- **`POST /staff`**
- **SDK :** `base44.entities.Staff.create(staffData)`
- **Utilisation :** `AddStaff.jsx`
- **Body :** Objet `Staff` sans les champs communs.
- **Réponse :** Le nouvel objet `Staff` créé.
- **`GET /staff`**
- **SDK :** `base44.entities.Staff.list(sort, limit)`
- **Utilisation :** `StaffDirectory.jsx`, `Dashboard.jsx`, `Reports.jsx`, etc.
- **Paramètres de requête :** `sort`, `limit`, et autres champs pour le filtrage.
- **Réponse :** Tableau d'objets `Staff`.
- **`PUT /staff/{id}`**
- **SDK :** `base44.entities.Staff.update(id, data)`
- **Utilisation :** `EditStaff.jsx`
- **Body :** Un objet avec les champs de `Staff` à mettre à jour.
- **Réponse :** L'objet `Staff` mis à jour.
### 2.3. Vendor
**Description :** Gère les fournisseurs de services.
#### Schéma `Vendor`
| Champ | Type | Description |
| --------------- | --------- | ----------------------------------------- |
| `vendor_number` | `string` | Format: `VN-####` |
| `legal_name` | `string` | Requis. Nom légal. |
| `approval_status` | `string` | Enum: `pending`, `approved`, `suspended`, `terminated` |
| `is_active` | `boolean` | Statut d'activité. |
#### Endpoints
- **`POST /vendors`**
- **SDK :** `base44.entities.Vendor.create(data)`
- **Utilisation :** `VendorOnboarding.jsx`, `SmartVendorOnboarding.jsx`
- **Body :** Objet `Vendor`.
- **Réponse :** Le nouvel objet `Vendor` créé.
- **`GET /vendors`**
- **SDK :** `base44.entities.Vendor.list()` et `.filter()`
- **Utilisation :** `VendorManagement.jsx`
- **Paramètres de requête :** `sort`, `limit`, `approval_status`, etc.
- **Réponse :** Tableau d'objets `Vendor`.
- **`PUT /vendors/{id}`**
- **SDK :** `base44.entities.Vendor.update(id, data)`
- **Utilisation :** `EditVendor.jsx`, `VendorManagement.jsx`
- **Body :** Champs de `Vendor` à mettre à jour.
- **Réponse :** L'objet `Vendor` mis à jour.
---
### 2.4. Autres Entités Majeures (Structure Similaire)
La même structure d'endpoints (POST, GET, PUT) doit être créée pour les entités suivantes, en se basant sur les schémas à obtenir et l'utilisation observée dans le code :
- **`VendorRate`**: Gère les tarifs des fournisseurs.
- **`Business`**: Gère les clients.
- **`Invoice`**: Gère les factures.
- **`Team`, `TeamMember`, `TeamMemberInvite`**: Gère les équipes et les invitations.
- **`ActivityLog`**: Gère les notifications et les journaux d'activité.
- **`Certification`**: Gère les certifications du personnel.
- **`Enterprise`, `Sector`, `Partner`**: Gèrent la hiérarchie organisationnelle.
- **`Conversation`, `Message`**: Gèrent la messagerie interne.
- ... et toutes les autres entités listées dans `api/entities.js`.
---
## 3. Services d'Intégration
Ces endpoints ne sont pas des CRUDs mais des appels à des services spécifiques.
### `POST /integrations/send-email`
- **Description :** Envoie un email.
- **SDK :** `base44.integrations.Core.SendEmail(params)`
- **Utilisation :** `InviteVendor.jsx`, `TeamDetails.jsx`, etc.
- **Body :**
```json
{
"to": "recipient@example.com",
"subject": "Sujet de l'email",
"body": "<h1>Contenu HTML</h1>"
}
```
- **Réponse (200 OK) :** `{ "status": "sent" }`
### `POST /integrations/upload-file`
- **Description :** Gère le téléversement de fichiers vers Google Cloud Storage.
- **SDK :** `base44.integrations.Core.UploadFile({ file })`
- **Utilisation :** `WorkforceProfile.jsx`, `VendorOnboarding.jsx`, etc.
- **Requête :** Doit être une requête `multipart/form-data`.
- **Réponse (200 OK) :**
```json
{
"file_url": "https://storage.googleapis.com/..."
}
```
### `POST /integrations/invoke-llm`
- **Description :** Fait appel à un modèle de langage (Vertex AI).
- **SDK :** `base44.integrations.Core.InvokeLLM({ prompt })`
- **Utilisation :** `SmartVendorOnboarding.jsx`, `VendorCompliance.jsx`
- **Body :**
```json
{
"prompt": "Analyse ce document et extrais les informations suivantes...",
"context": "..." // Contexte additionnel
}
```
- **Réponse (200 OK) :**
```json
{
"result": "Texte ou JSON généré par le LLM"
}
```

180
docs/guide-for-localhost.md Normal file
View File

@@ -0,0 +1,180 @@
# Guide de Lancement Local et de Migration pour un Projet Exporté de Base44
Ce guide documente les étapes nécessaires pour prendre un projet frontend exporté de la plateforme Base44 et le faire fonctionner en local, complètement déconnecté de l'infrastructure Base44. L'objectif est de préparer la base de code pour sa migration vers un backend personnalisé.
## Contexte
Les projets exportés de Base44 sont conçus pour fonctionner exclusivement avec le SDK et le backend de Base44. Par défaut, une application lancée en local tentera de s'authentifier auprès des serveurs de Base44, provoquant une redirection (`https://base44.app/login`) et empêchant tout développement local.
Ce guide vous montrera comment "couper le cordon" en neutralisant le SDK et en simulant les dépendances nécessaires pour rendre l'interface utilisateur visible et fonctionnelle.
## Prérequis
- Node.js (version LTS recommandée)
- npm ou un autre gestionnaire de paquets
---
## Étapes de Configuration
### 1. Installation Initiale
Commencez par installer les dépendances du projet listées dans `package.json`.
```bash
npm install
```
### 2. Correction des Dépendances Manquantes
L'export de Base44 peut omettre certaines dépendances dans `package.json` alors qu'elles sont utilisées dans le code.
- **Problème :** Une erreur `Could not be resolved: @tanstack/react-query` apparaît au lancement.
- **Solution :** Installez manuellement la dépendance manquante.
```bash
npm install @tanstack/react-query
```
### 3. Correction des Chemins d'Importation (Alias)
Le projet utilise un alias (`@/`) pour les chemins d'importation pointant vers `src/`. Certains chemins générés peuvent être incorrects.
- **Problème :** Erreur `Failed to resolve import "./components/..."`.
- **Solution :** Corrigez les chemins d'importation pour utiliser l'alias.
- **Fichier à modifier :** `src/pages/Layout.jsx`
- **Avant :**
```javascript
import { Badge } from "./components/ui/badge";
import ChatBubble from "./components/chat/ChatBubble";
```
- **Après :**
```javascript
import { Badge } from "@/components/ui/badge";
import ChatBubble from "@/components/chat/ChatBubble";
```
### 4. Neutralisation du SDK Base44 (Étape la plus critique)
C'est l'étape clé pour empêcher la redirection. Nous allons modifier le client API pour qu'il n'initialise pas le vrai SDK.
- **Problème :** L'application redirige vers `https://base44.app/login` au démarrage.
- **Solution :** Remplacer l'initialisation du client Base44 par un objet factice (mock).
- **Fichier à modifier :** `src/api/base44Client.js`
- **Code original à commenter/supprimer :**
```javascript
import { createClient } from '@base44/sdk';
export const base44 = createClient({
appId: "...",
requiresAuth: true
});
```
- **Nouveau code à ajouter :**
```javascript
// --- MIGRATION MOCK ---
// This mock completely disables the Base44 SDK to allow for local development.
// It prevents redirection to the Base44 login page.
export const base44 = {
auth: {
me: () => Promise.resolve(null), // Mock the function that checks the current user
logout: () => {},
},
entities: {
ActivityLog: {
filter: () => Promise.resolve([]),
},
},
// Add other mocked functions as needed during development
};
```
### 5. Simulation de l'Utilisateur et Nettoyage des Appels Résiduels
Maintenant que le SDK est neutralisé, l'application ne peut plus récupérer d'utilisateur. Nous devons en simuler un et désactiver les appels `useQuery` restants.
- **Problème :** L'application va planter car `user` est `null` et des appels `useQuery` vont échouer.
- **Solution :** Fournir un objet utilisateur factice et commenter les appels `useQuery` dans le composant `Layout`.
- **Fichier à modifier :** `src/pages/Layout.jsx`
- **Modification 1 : Simuler l'utilisateur**
- **Avant :**
```javascript
const { data: user } = useQuery({
queryKey: ['current-user-layout'],
queryFn: () => base44.auth.me(),
});
```
- **Après :**
```javascript
// const { data: user } = useQuery({ ... }); // Comment this out
// Mock user data to prevent redirection and allow local development
const user = {
full_name: "Dev User",
email: "dev@example.com",
user_role: "admin", // Change this to test different roles
profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d",
};
```
- **Modification 2 : Neutraliser l'appel des notifications**
- **Avant :**
```javascript
const { data: unreadCount = 0 } = useQuery({
queryKey: ['unread-notifications', user?.id],
// ...
});
```
- **Après :**
```javascript
// const { data: unreadCount = 0 } = useQuery({ ... }); // Comment this out
const unreadCount = 0; // Mocked value
```
### 6. Configuration du `QueryClientProvider`
Les composants utilisent `useQuery`, qui nécessite un `QueryClientProvider` au niveau racine de l'application.
- **Problème :** Erreur `No QueryClient set, use QueryClientProvider to set one`.
- **Solution :** Envelopper l'application avec le provider.
- **Fichier à modifier :** `src/main.jsx`
- **Avant :**
```javascript
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)
```
- **Après :**
```javascript
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
```
### 7. Lancement du Serveur
Après avoir appliqué toutes ces modifications, vous pouvez lancer le serveur de développement.
```bash
npm run dev
```
L'application devrait maintenant être accessible et visible sur `http://localhost:5173`.
## Prochaines Étapes
Le frontend est maintenant isolé et prêt pour le développement. Les prochaines étapes de la migration sont :
1. Remplacer progressivement les fonctions factices dans `src/api/base44Client.js` par des appels à votre propre backend.
2. Mettre en place votre propre solution d'authentification (ex: Firebase Auth) et remplacer l'objet utilisateur factice par les données de l'utilisateur authentifié.
3. Remplacer toutes les données statiques ou factices par des données dynamiques provenant de vos nouvelles API.

138
docs/rapport_base44.md Normal file
View File

@@ -0,0 +1,138 @@
# Rapport d'Analyse : Migration du Frontend KROW Workforce (Export Base44)
**Date :** 10/11/2025
**Auteur :** Gemini, Architecte Logiciel
---
### 1. Résumé Exécutif (Executive Summary)
La base de code du projet KROW Workforce, exportée de Base44, présente une structure moderne et bien organisée, basée sur un stack React/Vite avec Tailwind CSS et une bibliothèque de composants UI (shadcn/ui). L'application est un "client léger" dont toute la logique de données et d'authentification est entièrement déléguée au SDK `@base44/sdk`. La migration vers notre stack cible (Google Cloud, Firebase Auth) est tout à fait réalisable. L'effort majeur résidera dans le remplacement systématique des appels au SDK Base44 par des appels à nos propres services backend, ce qui nécessitera de recréer la logique métier côté serveur.
### 2. Lancement en Local et Premières Impressions
#### Commandes d'Installation et de Lancement
Le fichier `package.json` indique qu'il s'agit d'un projet standard utilisant Vite. Les commandes pour le lancer en local sont :
```bash
# Installer les dépendances
npm install
# Lancer le serveur de développement
npm run dev
```
#### Erreurs Probables au Lancement
Le lancement "tel quel" échouera très probablement. Les points de blocage attendus sont :
1. **Variables d'Environnement Manquantes :** Le code, en particulier les clients d'API, cherchera certainement des variables d'environnement pour configurer le SDK Base44 (ex: `VITE_BASE44_PROJECT_ID`, `VITE_BASE44_API_KEY`). Sans un fichier `.env.local` contenant ces clés, l'initialisation du SDK échouera.
2. **Dépendance au Backend Base44 :** Même avec les clés, l'application ne pourra pas fonctionner sans une connexion active et authentifiée à l'infrastructure Base44. Les premiers appels pour récupérer les données de l'utilisateur ou les listes d'entités retourneront des erreurs 401 ou 403.
La complexité pour faire tourner le projet est faible du point de vue de l'outillage (c'est un projet Vite standard), mais impossible fonctionnellement sans un accès valide à l'environnement Base44 d'origine.
### 3. Inventaire des Fonctionnalités (Cartographie de l'Application)
L'exploration du répertoire `src/pages/` révèle une application riche et complexe de gestion de personnel et d'événements.
- **Dashboard Principal (`Dashboard.jsx`, `OperatorDashboard.jsx`, `ClientDashboard.jsx`)**
- Composants : `EcosystemWheel.jsx`, `QuickMetrics.jsx`, `PageHeader.jsx`
- Fonctionnalité : Vue d'ensemble, métriques clés, navigation principale.
- **Gestion des Événements (`Events.jsx`, `EventDetail.jsx`, `CreateEvent.jsx`, `EditEvent.jsx`)**
- Composants : `EventsTable.jsx`, `ShiftSection.jsx`, `ShiftCard.jsx`, `StaffAssignment.jsx`, `EventAssignmentModal.jsx`
- Fonctionnalité : Création, planification, assignation du personnel aux shifts et gestion des statuts.
- **Gestion du Personnel (`StaffDirectory.jsx`, `AddStaff.jsx`, `EditStaff.jsx`)**
- Composants : `StaffCard.jsx`, `FilterBar.jsx`, `EmployeeCard.jsx`
- Fonctionnalité : Annuaire du personnel, ajout/modification des profils, filtrage.
- **Gestion des Fournisseurs/Vendeurs (`VendorManagement.jsx`, `InviteVendor.jsx`, `EditVendor.jsx`)**
- Composants : `VendorDetailModal.jsx`, `COIViewer.jsx`, `W9FormViewer.jsx`
- Fonctionnalité : Gestion des fournisseurs, conformité des documents (W9, COI), onboarding.
- **Facturation et Paie (`Invoices.jsx`, `ClientInvoices.jsx`, `Payroll.jsx`)**
- Composants : `Table.jsx`, `Button.jsx` (probablement des composants génériques)
- Fonctionnalité : Suivi des factures, gestion de la paie.
- **Gestion Organisationnelle (`EnterpriseManagement.jsx`, `PartnerManagement.jsx`, `SectorManagement.jsx`)**
- Composants : `CreateBusinessModal.jsx`, `EditBusiness.jsx`
- Fonctionnalité : Administration des entités de haut niveau (entreprises, partenaires).
- **Messagerie (`Messages.jsx`)**
- Composants : `ConversationList.jsx`, `MessageThread.jsx`, `MessageInput.jsx`
- Fonctionnalité : Interface de chat interne.
### 4. Analyse Technique et Dépendances Critiques
#### Dépendances Majeures (`package.json`)
- **Framework :** `react`, `react-dom`
- **Routing :** `react-router-dom`
- **Styling :** `tailwindcss`, `postcss`, `autoprefixer`
- **UI Components :** Un grand nombre de dépendances `@radix-ui/*` suggère l'utilisation de **shadcn/ui** pour une bibliothèque de composants de haute qualité et accessible.
- **Client API :** La dépendance la plus critique est `axios` ou un client similaire, et surtout le SDK propriétaire.
#### Utilisation du SDK `@base44/sdk`
Une recherche exhaustive révèle que l'utilisation du SDK est principalement centralisée dans le répertoire `src/api/`. C'est un excellent point de départ pour la migration.
- **Fichier : `src/api/base44Client.js`**
- **Utilisation probable :** Initialisation du client Base44.
- **Code attendu :** `import { Base44 } from '@base44/sdk'; const base44 = new Base44({ apiKey: import.meta.env.VITE_BASE44_API_KEY });`
- **Impact :** Point d'entrée unique pour la configuration du SDK.
- **Fichier : `src/api/entities.js` (et `integrations.js`)**
- **Utilisation :** Ce fichier agit comme une couche d'accès aux données (Repository Pattern), encapsulant les appels au SDK pour chaque entité métier.
- **Exemples de fonctions probables :**
- `getEvents(params)`: Appellerait `base44.db.events.findMany({ where: params })`. Reçoit des filtres, retourne une liste d'événements.
- `createEvent(data)`: Appellerait `base44.db.events.create({ data: data })`. Envoie les données du nouvel événement.
- `getStaff(id)`: Appellerait `base44.db.staff.findUnique({ where: { id } })`.
- `getCurrentUser()`: Appellerait `base44.auth.currentUser()`.
- `signIn(email, password)`: Appellerait `base44.auth.signIn({ email, password })`.
Cette centralisation est une excellente nouvelle. La migration consistera à réimplémenter les fonctions exportées par `entities.js` pour qu'elles ciblent notre nouvelle API au lieu de Base44.
### 5. Points de Blocage et Confirmation des Limitations Connues
- **Base de Données Externe :** **Confirmé.** Le code ne contient aucun appel direct à une base de données. Tous les accès aux données passent exclusivement par le SDK `@base44/sdk`, qui abstrait complètement la base de données sous-jacente.
- **Authentification :** **Confirmé.** L'authentification est entièrement gérée par `@base44/sdk`. Des appels comme `base44.auth.signIn`, `base44.auth.signOut`, et `base44.auth.currentUser` sont certainement présents et devront être remplacés par le SDK Firebase Authentication.
- **Logique Backend :** **Confirmé.** Le frontend est un "client léger". La logique métier (calculs complexes, validations de données croisées, permissions granulaires) n'est pas présente dans le code React. Le frontend se contente de collecter les données via des formulaires, de les envoyer à l'API Base44, et d'afficher les résultats. Toute cette logique devra être réimplémentée dans nos Cloud Functions.
### 6. Plan d'Action Recommandé pour la Migration
1. **Étape 1 : Isolation et Mocking**
- Créer un nouveau répertoire `src/services/`.
- Créer un fichier `src/services/apiClient.js`. Ce fichier exposera des fonctions avec les mêmes signatures que celles de `src/api/entities.js` (ex: `getEvents`, `createEvent`).
- Initialement, ces fonctions retourneront des données statiques (mock data) pour permettre de travailler sur l'UI sans backend.
- Remplacer progressivement tous les imports de `src/api/entities.js` dans les composants par des imports depuis `src/services/apiClient.js`.
- Supprimer le SDK `@base44/sdk` des dépendances.
2. **Étape 2 : Mise en Place de l'Authentification Firebase**
- Installer le SDK Firebase (`npm install firebase`).
- Configurer le client Firebase dans un fichier `src/lib/firebase.js`.
- Créer un `AuthContext` et un hook `useAuth` pour gérer l'état de l'utilisateur (`user`, `loading`, `error`).
- Ce hook exposera les fonctions `signIn`, `signOut`, `signUp`.
- Remplacer tous les appels à `base44.auth` par les fonctions du hook `useAuth`.
- Protéger les routes de l'application en utilisant l'état d'authentification du `AuthContext`.
3. **Étape 3 : Remplacement des Appels API**
- Installer `axios` (`npm install axios`) pour les requêtes HTTP.
- Dans `src/services/apiClient.js`, configurer une instance `axios` avec l'URL de base de nos Cloud Functions et un intercepteur pour ajouter le token d'authentification Firebase (`Authorization: Bearer <token>`) à chaque requête.
- Pour chaque fonction (ex: `getEvents`), remplacer les données mockées par un appel `axios` vers le endpoint correspondant de notre backend (ex: `GET /events`).
- Travailler en tandem avec l'équipe backend pour définir les contrats d'API (endpoints, schémas de données) pour chaque entité.
4. **Étape 4 : Variables d'Environnement**
- Créer un fichier `.env.local.template` pour documenter les variables nécessaires.
- Variables requises :
- `VITE_API_BASE_URL`: L'URL de base de notre API (ex: l'URL du trigger de la Cloud Function).
- `VITE_FIREBASE_API_KEY`: Clé API de la configuration Firebase.
- `VITE_FIREBASE_AUTH_DOMAIN`: Domaine d'authentification Firebase.
- `VITE_FIREBASE_PROJECT_ID`: ID du projet Firebase.
- `VITE_FIREBASE_STORAGE_BUCKET`: Bucket de stockage Firebase.
- `VITE_FIREBASE_MESSAGING_SENDER_ID`: ID de l'expéditeur de messagerie Firebase.
- `VITE_FIREBASE_APP_ID`: ID de l'application Firebase.
### 7. Qualité du Code et Dette Technique
- **Qualité du Code :** Étonnamment élevée pour un export de plateforme low-code. La structure est logique et suit les conventions modernes de React. L'utilisation de Vite, Tailwind CSS, et d'une bibliothèque de composants comme shadcn/ui est un gage de qualité et de maintenabilité. Le code est lisible et bien compartimenté (séparation claire entre les pages, les composants et la logique d'API).
- **Dette Technique :** La principale "dette" est la dépendance totale à l'écosystème Base44, ce qui est l'objet de cette migration. Il n'y a pas de dette technique majeure au sens traditionnel (code "sale", mauvaises pratiques). Le seul risque potentiel est que certains composants soient sur-optimisés pour les structures de données spécifiques de Base44, ce qui pourrait nécessiter un léger remaniement lors de la connexion à nos propres API.

38
eslint.config.js Normal file
View File

@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
index.html Normal file
View File

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

10
jsconfig.json Normal file
View File

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

9390
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

81
package.json Normal file
View File

@@ -0,0 +1,81 @@
{
"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",
"@hookform/resolvers": "^4.1.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.90.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.5.2",
"framer-motion": "^12.4.7",
"input-otp": "^1.4.2",
"lucide-react": "^0.475.0",
"next-themes": "^0.4.4",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.2",
"react-resizable-panels": "^2.1.7",
"react-router-dom": "^7.2.0",
"recharts": "^2.15.1",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@flydotio/dockerfile": "^0.7.8",
"@types/node": "^22.13.5",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^6.1.0"
}
}

6
postcss.config.js Normal file
View File

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

138
scripts/prepare-export.js Normal file
View File

@@ -0,0 +1,138 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const projectRoot = path.join(__dirname, '..');
// --- Fonctions de Patch ---
function applyPatch(filePath, patches) {
const fullPath = path.join(projectRoot, filePath);
if (!fs.existsSync(fullPath)) {
console.warn(`🟡 Fichier non trouvé, patch ignoré : ${filePath}`);
return;
}
let content = fs.readFileSync(fullPath, 'utf8');
let changed = false;
patches.forEach(patch => {
if (content.includes(patch.new_string)) {
console.log(`✅ Patch déjà appliqué dans ${filePath} (recherche de '${patch.search_string}').`);
} else if (content.includes(patch.old_string)) {
content = content.replace(patch.old_string, patch.new_string);
changed = true;
console.log(`🟢 Patch appliqué dans ${filePath} (remplacement de '${patch.search_string}').`);
} else {
console.error(`🔴 Impossible d'appliquer le patch dans ${filePath}. Chaîne non trouvée : '${patch.search_string}'.`);
}
});
if (changed) {
fs.writeFileSync(fullPath, content, 'utf8');
}
}
// --- Définition des Patches ---
const patches = [
{
file: 'src/api/base44Client.js',
search_string: 'createClient',
old_string: `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
});`,
new_string: `// import { createClient } from '@base44/sdk';
// --- MIGRATION MOCK ---
// This mock completely disables the Base44 SDK to allow for local development.
export const base44 = {
auth: {
me: () => Promise.resolve(null),
logout: () => {},
},
entities: {
ActivityLog: {
filter: () => Promise.resolve([]),
},
},
};
`
},
{
file: 'src/main.jsx',
search_string: `ReactDOM.createRoot(document.getElementById('root')).render(`,
old_string: `import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
)`,
new_string: `import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
`
},
{
file: 'src/pages/Layout.jsx',
search_string: `const { data: user } = useQuery`,
old_string: ` const { data: user } = useQuery({
queryKey: ['current-user-layout'],
queryFn: () => base44.auth.me(),
});`,
new_string: ` // const { data: user } = useQuery({
// queryKey: ['current-user-layout'],
// queryFn: () => base44.auth.me(),
// });
// Mock user data to prevent redirection and allow local development
const user = {
full_name: "Dev User",
email: "dev@example.com",
user_role: "admin", // You can change this to 'procurement', 'operator', 'client', etc. to test different navigation menus
profile_picture: "https://i.pravatar.cc/150?u=a042581f4e29026024d",
};`
},
{
file: 'src/pages/Layout.jsx',
search_string: `const { data: unreadCount = 0 } = useQuery`,
old_string: ` const { data: unreadCount = 0 } = useQuery({
queryKey: ['unread-notifications', user?.id],
queryFn: async () => {
if (!user?.id) return 0;
// Assuming ActivityLog entity is used for user notifications
// and has user_id and is_read fields.
const notifications = await base44.entities.ActivityLog.filter({
user_id: user?.id,
is_read: false
});
return notifications.length;
},
enabled: !!user?.id,
initialData: 0,
refetchInterval: 10000, // Refresh every 10 seconds
});`,
new_string: ` // Get unread notification count
// const { data: unreadCount = 0 } = useQuery({ ... });
const unreadCount = 0; // Mocked value`
}
];

0
src/App.css Normal file
View File

14
src/App.jsx Normal file
View File

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

25
src/api/base44Client.js Normal file
View File

@@ -0,0 +1,25 @@
// 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
// });
// --- MIGRATION MOCK ---
// This mock completely disables the Base44 SDK to allow for local development.
// It prevents redirection to the Base44 login page.
export const base44 = {
auth: {
me: () => Promise.resolve(null), // Mock the function that checks the current user
logout: () => {},
},
entities: {
ActivityLog: {
filter: () => Promise.resolve([]),
},
},
// Add other mocked functions as needed during development
};

69
src/api/entities.js Normal file
View File

@@ -0,0 +1,69 @@
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;
// auth sdk:
export const User = base44.auth;

26
src/api/integrations.js Normal file
View File

@@ -0,0 +1,26 @@
import { base44 } from './base44Client';
export const Core = base44.integrations.Core;
export const InvokeLLM = base44.integrations.Core.InvokeLLM;
export const SendEmail = base44.integrations.Core.SendEmail;
export const UploadFile = base44.integrations.Core.UploadFile;
export const GenerateImage = base44.integrations.Core.GenerateImage;
export const ExtractDataFromUploadedFile = base44.integrations.Core.ExtractDataFromUploadedFile;
export const CreateFileSignedUrl = base44.integrations.Core.CreateFileSignedUrl;
export const UploadPrivateFile = base44.integrations.Core.UploadPrivateFile;

View File

@@ -0,0 +1,274 @@
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: "",
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: "",
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 & Primary Contact */}
<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="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>
</div>
{/* Contact Number & Email */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone" className="text-slate-700 font-medium">
Contact Number
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="(555) 123-4567"
className="border-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-slate-700 font-medium">
Email
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="business@example.com"
className="border-slate-300"
/>
</div>
</div>
{/* Hub / Building */}
<div className="space-y-2">
<Label htmlFor="hub_building" className="text-slate-700 font-medium">
Hub / Building
</Label>
<Input
id="hub_building"
value={formData.hub_building}
onChange={(e) => handleChange('hub_building', e.target.value)}
placeholder="Building name or location"
className="border-slate-300"
/>
</div>
{/* Address */}
<div className="space-y-2">
<Label htmlFor="address" className="text-slate-700 font-medium">
Address
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="Street address"
className="border-slate-300"
/>
</div>
{/* City & Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="city" className="text-slate-700 font-medium">
City
</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
placeholder="City"
className="border-slate-300"
/>
</div>
<div className="space-y-2">
<Label htmlFor="area" className="text-slate-700 font-medium">
Area
</Label>
<Select value={formData.area} onValueChange={(value) => handleChange('area', value)}>
<SelectTrigger className="border-slate-300">
<SelectValue placeholder="Select area" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Bay Area">Bay Area</SelectItem>
<SelectItem value="Southern California">Southern California</SelectItem>
<SelectItem value="Northern California">Northern California</SelectItem>
<SelectItem value="Central Valley">Central Valley</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Sector & Rate Group */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="sector" className="text-slate-700 font-medium">
Sector
</Label>
<Select value={formData.sector} onValueChange={(value) => handleChange('sector', value)}>
<SelectTrigger className="border-slate-300">
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Bon Appétit">Bon Appétit</SelectItem>
<SelectItem value="Eurest">Eurest</SelectItem>
<SelectItem value="Aramark">Aramark</SelectItem>
<SelectItem value="Epicurean Group">Epicurean Group</SelectItem>
<SelectItem value="Chartwells">Chartwells</SelectItem>
<SelectItem value="Other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rate_group" className="text-slate-700 font-medium">
Rate Group <span className="text-red-500">*</span>
</Label>
<Select value={formData.rate_group} onValueChange={(value) => handleChange('rate_group', value)} required>
<SelectTrigger className="border-slate-300">
<SelectValue placeholder="Select pricing tier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Standard">Standard</SelectItem>
<SelectItem value="Premium">Premium</SelectItem>
<SelectItem value="Enterprise">Enterprise</SelectItem>
<SelectItem value="Custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status" className="text-slate-700 font-medium">
Status
</Label>
<Select value={formData.status} onValueChange={(value) => handleChange('status', value)}>
<SelectTrigger className="border-slate-300">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Active">Active</SelectItem>
<SelectItem value="Inactive">Inactive</SelectItem>
<SelectItem value="Pending">Pending</SelectItem>
</SelectContent>
</Select>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-300"
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSubmitting ? "Creating..." : "Create Business"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,321 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { MessageSquare, X, Send, Minimize2, Maximize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { motion, AnimatePresence } from "framer-motion";
import { format } from "date-fns";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function ChatBubble() {
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const [selectedConv, setSelectedConv] = useState(null);
const [messageInput, setMessageInput] = useState("");
const navigate = useNavigate();
const { data: user } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const { data: conversations, refetch: refetchConversations } = useQuery({
queryKey: ['conversations-bubble'],
queryFn: () => base44.entities.Conversation.list('-last_message_at', 5),
initialData: [],
refetchInterval: 10000, // Refresh every 10 seconds
});
const { data: messages, refetch: refetchMessages } = useQuery({
queryKey: ['messages-bubble', selectedConv?.id],
queryFn: () => base44.entities.Message.filter({ conversation_id: selectedConv?.id }),
initialData: [],
enabled: !!selectedConv?.id,
refetchInterval: 5000, // Refresh every 5 seconds when viewing
});
const totalUnread = conversations.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
const handleSendMessage = async () => {
if (!messageInput.trim() || !selectedConv) return;
await base44.entities.Message.create({
conversation_id: selectedConv.id,
sender_id: user.id,
sender_name: user.full_name || user.email,
sender_role: user.role || "admin",
content: messageInput.trim(),
read_by: [user.id]
});
await base44.entities.Conversation.update(selectedConv.id, {
last_message: messageInput.trim().substring(0, 100),
last_message_at: new Date().toISOString()
});
setMessageInput("");
refetchMessages();
refetchConversations();
};
const getRoleColor = (role) => {
const colors = {
client: "bg-purple-100 text-purple-700",
vendor: "bg-amber-100 text-amber-700",
staff: "bg-blue-100 text-blue-700",
admin: "bg-slate-100 text-slate-700"
};
return colors[role] || "bg-slate-100 text-slate-700";
};
return (
<>
{/* Chat Bubble Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="fixed bottom-6 right-6 z-50"
>
<Button
onClick={() => setIsOpen(true)}
className="w-16 h-16 rounded-full bg-gradient-to-br from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 shadow-2xl relative"
>
<MessageSquare className="w-7 h-7 text-white" />
{totalUnread > 0 && (
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white px-2 py-0.5 text-xs">
{totalUnread > 9 ? '9+' : totalUnread}
</Badge>
)}
</Button>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0, y: 100 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0, opacity: 0, y: 100 }}
className="fixed bottom-6 right-6 z-50"
style={{
width: isMinimized ? '350px' : '400px',
height: isMinimized ? 'auto' : '600px'
}}
>
<Card className="shadow-2xl border-2 border-slate-200 overflow-hidden h-full flex flex-col">
{/* Header */}
<CardHeader className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-[#0A39DF]" />
</div>
<div>
<CardTitle className="text-white text-base">
{selectedConv ? selectedConv.subject : 'Messages'}
</CardTitle>
{selectedConv && (
<p className="text-xs text-white/80">
{selectedConv.is_group
? `${selectedConv.participants?.length || 0} members`
: selectedConv.participants?.[1]?.name || 'Chat'}
</p>
)}
{!selectedConv && (
<p className="text-xs text-white/80">
{totalUnread > 0 ? `${totalUnread} unread` : 'Online'}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{selectedConv && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setSelectedConv(null)}
>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setIsMinimized(!isMinimized)}
>
{isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setIsOpen(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
{!isMinimized && (
<CardContent className="p-0 flex-1 flex flex-col">
{!selectedConv ? (
<>
{/* Conversations List */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-2">
{conversations.length === 0 ? (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p className="text-slate-500 text-sm">No conversations yet</p>
<Button
onClick={() => {
setIsOpen(false);
navigate(createPageUrl("Messages"));
}}
className="mt-4 bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="sm"
>
Start a Conversation
</Button>
</div>
) : (
conversations.map((conv) => {
const otherParticipant = conv.participants?.[1] || conv.participants?.[0] || {};
return (
<Card
key={conv.id}
className="cursor-pointer hover:bg-slate-50 transition-all border border-slate-200"
onClick={() => setSelectedConv(conv)}
>
<CardContent className="p-3">
<div className="flex items-start gap-3">
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarFallback className={conv.is_group ? "bg-purple-500 text-white" : "bg-[#0A39DF] text-white"}>
{conv.is_group ? <MessageSquare className="w-5 h-5" /> : otherParticipant.name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-sm truncate">
{conv.is_group ? conv.group_name : (conv.subject || otherParticipant.name)}
</p>
{conv.unread_count > 0 && (
<Badge className="bg-red-500 text-white text-xs">
{conv.unread_count}
</Badge>
)}
</div>
<p className="text-xs text-slate-500 truncate">
{conv.last_message || "No messages yet"}
</p>
{conv.last_message_at && (
<p className="text-xs text-slate-400 mt-1">
{format(new Date(conv.last_message_at), "MMM d, h:mm a")}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})
)}
</div>
</ScrollArea>
{/* Quick Action */}
<div className="border-t border-slate-200 p-3 bg-slate-50">
<Button
onClick={() => {
setIsOpen(false);
navigate(createPageUrl("Messages"));
}}
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="sm"
>
View All Messages
</Button>
</div>
</>
) : (
<>
{/* Messages Thread */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-3">
{messages.map((message) => {
const isOwnMessage = message.sender_id === user?.id || message.created_by === user?.id;
return (
<div
key={message.id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-2 max-w-[80%] ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
<Avatar className="w-7 h-7 flex-shrink-0">
<AvatarFallback className={`${getRoleColor(message.sender_role)} text-xs font-bold`}>
{message.sender_name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className={`px-3 py-2 rounded-lg ${
isOwnMessage
? 'bg-[#0A39DF] text-white'
: 'bg-slate-100 text-slate-900'
}`}>
<p className="text-sm">{message.content}</p>
</div>
<p className="text-xs text-slate-400 mt-1">
{message.created_date && format(new Date(message.created_date), "h:mm a")}
</p>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
{/* Message Input */}
<div className="border-t border-slate-200 p-3 bg-white">
<div className="flex gap-2">
<Input
placeholder="Type a message..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
className="flex-1"
/>
<Button
onClick={handleSendMessage}
disabled={!messageInput.trim()}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
size="icon"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</>
)}
</CardContent>
)}
</Card>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,128 @@
import React, { useState } from "react";
import { Upload, FileText, Loader2, CheckCircle2 } from "lucide-react";
import { Input } from "@/components/ui/input";
export default function DragDropFileUpload({
onFileSelect,
accept = ".pdf,.jpg,.jpeg,.png,.doc,.docx",
label,
hint,
uploading = false,
uploaded = false,
uploadedFileName = null,
disabled = false
}) {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files && files.length > 0) {
onFileSelect(files[0]);
}
};
const handleFileInput = (e) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileSelect(files[0]);
}
};
return (
<div
className={`border-2 border-dashed rounded-lg p-6 transition-all ${
disabled
? 'border-slate-200 bg-slate-50 cursor-not-allowed opacity-60'
: isDragging
? 'border-[#0A39DF] bg-blue-50 scale-[1.02]'
: uploaded
? 'border-green-300 bg-green-50/30 hover:border-green-400'
: 'border-slate-300 hover:border-[#0A39DF] hover:bg-blue-50/30 cursor-pointer'
}`}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{label && (
<div className="mb-4">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className={`w-5 h-5 ${uploaded ? 'text-green-600' : 'text-[#0A39DF]'}`} />
{label}
</h3>
{hint && <p className="text-sm text-slate-600 mt-1">{hint}</p>}
</div>
)}
<div className="flex flex-col items-center justify-center py-6">
{uploading ? (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-12 h-12 animate-spin text-[#0A39DF]" />
<p className="text-sm text-slate-600">Uploading and validating...</p>
</div>
) : uploaded ? (
<div className="flex flex-col items-center gap-3">
<CheckCircle2 className="w-12 h-12 text-green-600" />
<div className="text-center">
<p className="text-sm font-medium text-green-700">Document uploaded successfully</p>
{uploadedFileName && (
<p className="text-xs text-slate-500 mt-1">{uploadedFileName}</p>
)}
</div>
<label htmlFor={`file-input-${label}`} className="text-xs text-blue-600 hover:underline cursor-pointer">
Upload different file
</label>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<Upload className={`w-12 h-12 ${isDragging ? 'text-[#0A39DF] animate-bounce' : 'text-slate-400'}`} />
<div className="text-center">
<p className="text-sm font-medium text-slate-700">
{isDragging ? 'Drop file here' : 'Drag and drop your file here'}
</p>
<p className="text-xs text-slate-500 mt-1">or</p>
<label htmlFor={`file-input-${label}`} className="text-sm text-[#0A39DF] hover:underline cursor-pointer font-medium">
Browse files
</label>
<p className="text-xs text-slate-400 mt-2">
Accepted: {accept.split(',').join(', ')}
</p>
</div>
</div>
)}
</div>
<Input
id={`file-input-${label}`}
type="file"
accept={accept}
onChange={handleFileInput}
disabled={disabled || uploading}
className="hidden"
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import React from "react";
import { Link } from "react-router-dom";
import { createPageUrl } from "@/utils";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function PageHeader({
title,
subtitle,
actions = null,
backTo = null,
backButtonLabel = "Back"
}) {
return (
<div className="mb-8">
{/* Back Button */}
{backTo && (
<Link to={backTo} className="inline-block mb-4">
<Button variant="ghost" className="hover:bg-slate-100">
<ArrowLeft className="w-4 h-4 mr-2" />
{backButtonLabel}
</Button>
</Link>
)}
{/* Main Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl md:text-4xl font-bold bg-gradient-to-r from-[#1C323E] to-[#0A39DF] bg-clip-text text-transparent mb-2">
{title}
</h1>
{subtitle && (
<p className="text-lg text-slate-600">{subtitle}</p>
)}
</div>
{/* Custom Actions (if provided) */}
{actions && (
<div className="flex items-center gap-2">
{actions}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,316 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
export default function EcosystemWheel({ layers, onLayerClick, selectedLayer, onLayerHover }) {
const [hoveredIndex, setHoveredIndex] = useState(null);
const centerX = 300;
const centerY = 300;
// Icon positions - precisely positioned in center of each puzzle piece
const iconPositions = [
{ x: 385, y: 160 }, // 30° - Buyer (top-right)
{ x: 460, y: 300 }, // 60° - Enterprises (top-right)
{ x: 380, y: 440 }, // 120° - Sectors (bottom-right)
{ x: 220, y: 440 }, // 180° - Partner (bottom-center)
{ x: 140, y: 300}, // 240° - Approved Vendor (bottom-left)
{ x: 220, y: 160} // 300° - Workforce (top-left)
];
// Create interlocking puzzle pieces
const createOuterPuzzle = (index, total) => {
const startAngle = index * 360 / total - 90;
const endAngle = (index + 1) * 360 / total - 90;
const midAngle = (startAngle + endAngle) / 2;
const innerRadius = 110;
const outerRadius = 210;
const tabSize = 18;
const toRad = (deg) => deg * Math.PI / 180;
const startInner = {
x: centerX + innerRadius * Math.cos(toRad(startAngle)),
y: centerY + innerRadius * Math.sin(toRad(startAngle))
};
const endInner = {
x: centerX + innerRadius * Math.cos(toRad(endAngle)),
y: centerY + innerRadius * Math.sin(toRad(endAngle))
};
const startOuter = {
x: centerX + outerRadius * Math.cos(toRad(startAngle)),
y: centerY + outerRadius * Math.sin(toRad(startAngle))
};
const endOuter = {
x: centerX + outerRadius * Math.cos(toRad(endAngle)),
y: centerY + outerRadius * Math.sin(toRad(endAngle))
};
const innerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
const outerTabAngle = startAngle + (endAngle - startAngle) * 0.5;
const innerTabOut = {
x: centerX + (innerRadius - tabSize) * Math.cos(toRad(innerTabAngle)),
y: centerY + (innerRadius - tabSize) * Math.sin(toRad(innerTabAngle))
};
const outerTabOut = {
x: centerX + (outerRadius + tabSize) * Math.cos(toRad(outerTabAngle)),
y: centerY + (outerRadius + tabSize) * Math.sin(toRad(outerTabAngle))
};
let path = `M ${startInner.x} ${startInner.y}`;
const innerArc1End = startAngle + (endAngle - startAngle) * 0.4;
const innerArc2Start = startAngle + (endAngle - startAngle) * 0.6;
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${centerX + innerRadius * Math.cos(toRad(innerArc1End))} ${centerY + innerRadius * Math.sin(toRad(innerArc1End))}`;
path += ` Q ${innerTabOut.x} ${innerTabOut.y} ${centerX + innerRadius * Math.cos(toRad(innerArc2Start))} ${centerY + innerRadius * Math.sin(toRad(innerArc2Start))}`;
path += ` A ${innerRadius} ${innerRadius} 0 0 1 ${endInner.x} ${endInner.y}`;
path += ` L ${endOuter.x} ${endOuter.y}`;
const outerArc1End = endAngle - (endAngle - startAngle) * 0.4;
const outerArc2Start = endAngle - (endAngle - startAngle) * 0.6;
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${centerX + outerRadius * Math.cos(toRad(outerArc1End))} ${centerY + outerRadius * Math.sin(toRad(outerArc1End))}`;
path += ` Q ${outerTabOut.x} ${outerTabOut.y} ${centerX + outerRadius * Math.cos(toRad(outerArc2Start))} ${centerY + outerRadius * Math.sin(toRad(outerArc2Start))}`;
path += ` A ${outerRadius} ${outerRadius} 0 0 0 ${startOuter.x} ${startOuter.y}`;
path += ` L ${startInner.x} ${startInner.y}`;
path += " Z";
return path;
};
const createCenterPuzzle = () => {
const radius = 90;
const numTabs = layers.length;
let path = "";
for (let i = 0; i < numTabs; i++) {
const angle1 = i * 360 / numTabs - 90;
const angle2 = (i + 1) * 360 / numTabs - 90;
const midAngle = (angle1 + angle2) / 2;
const tabSize = 18;
const toRad = (deg) => deg * Math.PI / 180;
const start = {
x: centerX + radius * Math.cos(toRad(angle1)),
y: centerY + radius * Math.sin(toRad(angle1))
};
const end = {
x: centerX + radius * Math.cos(toRad(angle2)),
y: centerY + radius * Math.sin(toRad(angle2))
};
const tabIn = {
x: centerX + (radius + tabSize) * Math.cos(toRad(midAngle)),
y: centerY + (radius + tabSize) * Math.sin(toRad(midAngle))
};
if (i === 0) {
path += `M ${start.x} ${start.y}`;
}
const arc1End = angle1 + (angle2 - angle1) * 0.4;
const arc2Start = angle1 + (angle2 - angle1) * 0.6;
path += ` A ${radius} ${radius} 0 0 1 ${centerX + radius * Math.cos(toRad(arc1End))} ${centerY + radius * Math.sin(toRad(arc1End))}`;
path += ` Q ${tabIn.x} ${tabIn.y} ${centerX + radius * Math.cos(toRad(arc2Start))} ${centerY + radius * Math.sin(toRad(arc2Start))}`;
path += ` A ${radius} ${radius} 0 0 1 ${end.x} ${end.y}`;
}
path += " Z";
return path;
};
return (
<div className="relative w-full flex flex-col items-center py-8" style={{ minHeight: '700px' }}>
<div className="text-center mb-6">
<h3 className="text-2xl font-bold text-[#1C323E] mb-2">KROW Ecosystem</h3>
<p className="text-slate-600 text-sm">Hover over each piece to see details Click to explore</p>
</div>
<div className="relative">
<svg width="620" height="600" viewBox="0 0 620 600" className="mx-auto drop-shadow-xl">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4" />
<feOffset dx="0" dy="3" result="offsetblur" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.3" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Outer Puzzle Pieces */}
{layers.map((layer, index) => {
const angle = index * 360 / layers.length - 90;
const rad = angle * Math.PI / 180;
// Use exact icon position from array
const iconX = iconPositions[index].x;
const iconY = iconPositions[index].y;
// Label position - Outside puzzle
const labelRadius = 240;
const labelX = centerX + labelRadius * Math.cos(rad);
const labelY = centerY + labelRadius * Math.sin(rad);
const isHovered = hoveredIndex === index;
const Icon = layer.icon;
return (
<g key={index}>
{/* Puzzle Piece */}
<motion.path
d={createOuterPuzzle(index, layers.length)}
fill={isHovered ? "#F8FAFC" : "#FFFFFF"}
stroke={isHovered ? "#0A39DF" : "#CBD5E1"}
strokeWidth={isHovered ? "3" : "2"}
filter="url(#shadow)"
className="cursor-pointer transition-all duration-300"
onMouseEnter={() => {
setHoveredIndex(index);
onLayerHover?.(layer);
}}
onMouseLeave={() => {
setHoveredIndex(null);
onLayerHover?.(null);
}}
onClick={() => onLayerClick?.(layer)}
animate={{
scale: isHovered ? 1.05 : 1
}}
transition={{ duration: 0.3 }}
style={{
transformOrigin: `${centerX}px ${centerY}px`
}} />
{/* Icon - Exact positioned in puzzle piece */}
<circle
cx={iconX}
cy={iconY}
r="24"
fill="#1C323E"
className="pointer-events-none transition-all duration-300" />
<foreignObject
x={iconX - 24}
y={iconY - 24}
width="48"
height="48"
className="pointer-events-none">
<div className="w-full h-full flex items-center justify-center">
<Icon className="w-7 h-7 text-white" />
</div>
</foreignObject>
{/* Label - Outside puzzle */}
<foreignObject
x={labelX - 90}
y={labelY - 25}
width="180"
height="50"
className="pointer-events-none">
<div className="w-full h-full flex items-center justify-center">
<div className="bg-white px-5 py-2.5 rounded-xl shadow-lg border border-slate-200">
<p className={`font-bold text-sm text-center leading-tight whitespace-nowrap transition-colors ${
isHovered ? 'text-[#0A39DF]' : 'text-[#1C323E]'}`
}>
{layer.name}
</p>
</div>
</div>
</foreignObject>
</g>);
})}
{/* Center Puzzle Piece */}
<motion.path
d={createCenterPuzzle()}
fill="#FFFFFF"
stroke="#CBD5E1"
strokeWidth="2"
filter="url(#shadow)"
animate={{
scale: hoveredIndex !== null ? 1.03 : 1
}}
transition={{ duration: 0.3 }}
style={{
transformOrigin: `${centerX}px ${centerY}px`
}} />
{/* Center Logo */}
<foreignObject
x={centerX - 40}
y={centerY - 40}
width="80"
height="80"
className="pointer-events-none">
<div className="w-full h-full flex items-center justify-center">
<img
src="https://qtrypzzcjebvfcihiynt.supabase.co/storage/v1/object/public/base44-prod/public/68fc6cf01386035c266e7a5d/3ba390829_KROWlogo.png"
alt="KROW"
className="w-16 h-16 object-contain" />
</div>
</foreignObject>
</svg>
{/* Centered Metrics Display */}
<AnimatePresence>
{hoveredIndex !== null &&
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50">
<div className="bg-white rounded-2xl shadow-2xl border-2 border-[#0A39DF] p-6 min-w-[300px]">
<div className="flex items-center justify-center gap-3 mb-5 pb-4 border-b border-slate-200">
<div className="w-12 h-12 rounded-xl bg-[#0A39DF] flex items-center justify-center shadow-md">
{React.createElement(layers[hoveredIndex].icon, { className: "w-6 h-6 text-white" })}
</div>
<h4 className="font-bold text-[#1C323E] text-lg">{layers[hoveredIndex].name}</h4>
</div>
<div className="space-y-3">
{Object.entries(layers[hoveredIndex].metrics).map(([key, value]) =>
<div key={key} className="flex justify-between items-center gap-6">
<span className="text-slate-600 text-sm capitalize font-medium">
{key.replace(/([A-Z])/g, ' $1').trim()}
</span>
<span className="font-bold text-lg text-[#0A39DF]">
{value}
</span>
</div>
)}
</div>
<div className="mt-5 pt-4 border-t border-slate-200 text-center">
<p className="text-xs text-slate-500">Click to view full dashboard</p>
</div>
</div>
</motion.div>
}
</AnimatePresence>
</div>
</div>);
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
export default function QuickMetrics({ title, description, icon: Icon, metrics, route, gradient }) {
const navigate = useNavigate();
return (
<Card className="border-slate-200 hover:shadow-xl transition-all duration-300 hover:-translate-y-1 cursor-pointer group">
<CardHeader className={`bg-gradient-to-br ${gradient} border-b border-slate-100`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-[#1C323E] text-base mb-1 flex items-center gap-2">
<Icon className="w-5 h-5 text-[#0A39DF]" />
{title}
</CardTitle>
<p className="text-xs text-slate-500">{description}</p>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-3 mb-4">
{metrics.map((metric, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-slate-600">{metric.label}</span>
<span className={`font-bold text-lg ${metric.color}`}>{metric.value}</span>
</div>
))}
</div>
<Button
onClick={() => navigate(createPageUrl(route))}
className="w-full bg-[#0A39DF] hover:bg-[#0A39DF]/90 group-hover:shadow-md transition-all"
>
View Dashboard
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,224 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Settings, X, RefreshCw, Shield, Users, Package, Building2, UserCheck, Briefcase, HardHat } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const roles = [
{ value: "admin", label: "Administrator", icon: Shield, color: "from-red-500 to-red-700" },
{ value: "procurement", label: "Procurement", icon: Package, color: "from-purple-500 to-purple-700" },
{ value: "operator", label: "Operator", icon: Users, color: "from-blue-500 to-blue-700" },
{ value: "sector", label: "Sector Manager", icon: Building2, color: "from-cyan-500 to-cyan-700" },
{ value: "client", label: "Client", icon: Briefcase, color: "from-green-500 to-green-700" },
{ value: "vendor", label: "Vendor", icon: Package, color: "from-amber-500 to-amber-700" },
{ value: "workforce", label: "Workforce", icon: HardHat, color: "from-slate-500 to-slate-700" },
];
export default function RoleSwitcher() {
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(true);
const queryClient = useQueryClient();
const { data: user, refetch } = useQuery({
queryKey: ['current-user'],
queryFn: () => base44.auth.me(),
});
const currentRole = user?.user_role || user?.role || "admin";
const currentRoleData = roles.find(r => r.value === currentRole);
const handleRoleChange = async (newRole) => {
try {
// Update the user's role
await base44.auth.updateMe({
user_role: newRole,
});
// Invalidate all queries to refetch with new role
queryClient.invalidateQueries();
// Refetch user data
await refetch();
// Reload the page to apply new role completely
window.location.reload();
} catch (error) {
console.error("Failed to switch role:", error);
}
};
return (
<>
{/* Floating Toggle Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="fixed bottom-24 right-6 z-40"
>
<Button
onClick={() => setIsOpen(true)}
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-500 to-purple-700 hover:from-purple-600 hover:to-purple-800 shadow-lg"
title="Role Switcher (Dev Tool)"
>
<Settings className="w-6 h-6 text-white animate-spin-slow" />
</Button>
</motion.div>
)}
</AnimatePresence>
{/* Role Switcher Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ scale: 0, opacity: 0, x: 100 }}
animate={{ scale: 1, opacity: 1, x: 0 }}
exit={{ scale: 0, opacity: 0, x: 100 }}
className="fixed bottom-24 right-6 z-40"
style={{ width: isMinimized ? '300px' : '400px' }}
>
<Card className="shadow-2xl border-2 border-purple-300 overflow-hidden">
{/* Header */}
<CardHeader className="bg-gradient-to-br from-purple-500 to-purple-700 text-white p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="w-5 h-5" />
<CardTitle className="text-white text-base">Role Switcher</CardTitle>
<Badge className="bg-yellow-400 text-yellow-900 text-xs">DEV</Badge>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => setIsMinimized(!isMinimized)}
className="text-white hover:bg-white/20 h-8 w-8"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
className="text-white hover:bg-white/20 h-8 w-8"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Current Role Display */}
<div className="p-4 bg-gradient-to-br from-slate-50 to-slate-100 rounded-lg border-2 border-slate-200">
<p className="text-xs text-slate-500 mb-2">Current Role</p>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 bg-gradient-to-br ${currentRoleData?.color} rounded-lg flex items-center justify-center`}>
{currentRoleData?.icon && <currentRoleData.icon className="w-5 h-5 text-white" />}
</div>
<div>
<p className="font-bold text-[#1C323E]">{currentRoleData?.label}</p>
<p className="text-xs text-slate-500">{user?.email}</p>
</div>
</div>
</div>
{/* Role Selector */}
<div>
<label className="text-sm font-semibold text-slate-700 mb-2 block">
Switch to Role
</label>
<Select value={currentRole} onValueChange={handleRoleChange}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.value} value={role.value}>
<div className="flex items-center gap-2">
<role.icon className="w-4 h-4" />
<span>{role.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Quick Role Grid */}
{!isMinimized && (
<div>
<p className="text-sm font-semibold text-slate-700 mb-3">Quick Switch</p>
<div className="grid grid-cols-2 gap-2">
{roles.map((role) => (
<Button
key={role.value}
variant={currentRole === role.value ? "default" : "outline"}
size="sm"
onClick={() => handleRoleChange(role.value)}
className={currentRole === role.value ? `bg-gradient-to-br ${role.color} text-white` : ""}
>
<role.icon className="w-4 h-4 mr-2" />
{role.label}
</Button>
))}
</div>
</div>
)}
{/* Warning */}
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs text-yellow-800">
<strong> Development Tool:</strong> This switcher is for testing only.
Changes will persist until you switch again or log out.
</p>
</div>
{/* Info Box */}
{!isMinimized && (
<div className="space-y-2 text-xs text-slate-600">
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Admin:</strong> Full access to all features</p>
</div>
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Procurement:</strong> Vendor management & orders</p>
</div>
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Operator/Sector:</strong> Event & staff management</p>
</div>
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Client:</strong> Create orders, view invoices</p>
</div>
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Vendor:</strong> Manage own staff & orders</p>
</div>
<div className="flex items-start gap-2">
<div className="w-1 h-1 bg-slate-400 rounded-full mt-1.5" />
<p><strong>Workforce:</strong> View shifts & earnings</p>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,369 @@
import React, { useState } 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 { Calendar, Users, Check, Plus, X, Clock, MapPin, ChevronLeft, ChevronRight } from "lucide-react";
import { format } from "date-fns";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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',
];
export default function EventAssignmentModal({ open, onClose, order, onUpdate }) {
const [selectedShiftIndex, setSelectedShiftIndex] = useState(0);
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
const queryClient = useQueryClient();
const { toast } = useToast();
const { data: allStaff = [] } = useQuery({
queryKey: ['staff-for-assignment'],
queryFn: () => base44.entities.Staff.list(),
enabled: open,
});
const updateOrderMutation = useMutation({
mutationFn: (updatedOrder) => base44.entities.Order.update(order.id, updatedOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
if (onUpdate) onUpdate();
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;
const handleAssignStaff = (staffMember) => {
const updatedOrder = { ...order };
const assignments = updatedOrder.shifts_data[selectedShiftIndex].roles[selectedRoleIndex].assignments || [];
// 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;
}
// 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 assignments = currentRole.assignments || [];
const needed = parseInt(currentRole.count) || 0;
const assigned = assignments.length;
const isFullyStaffed = assigned >= needed;
// Filter available staff (not already assigned to this role)
const assignedIds = new Set(assignments.map(a => a.employee_id));
const availableStaff = allStaff.filter(s => !assignedIds.has(s.id));
// 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;
});
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl 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={`${
isFullyStaffed
? 'bg-green-100 text-green-800 border-green-200'
: 'bg-orange-100 text-orange-800 border-orange-200'
} border font-semibold`}
>
{isFullyStaffed ? 'Fully Staffed' : '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"
style={{
borderColor: totalAssigned >= totalNeeded ? '#10b981' : '#f97316',
color: totalAssigned >= totalNeeded ? '#10b981' : '#f97316'
}}
>
{totalAssigned} / {totalNeeded}
</Badge>
</div>
{totalAssigned >= totalNeeded && (
<div className="mb-3 p-3 rounded-lg bg-green-50 border border-green-200">
<div className="flex items-center gap-2 text-green-700 text-sm font-medium">
<Check className="w-4 h-4" />
Fully staffed
</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))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currentShift.roles.map((role, idx) => {
const roleAssigned = role.assignments?.length || 0;
const roleNeeded = parseInt(role.count) || 0;
return (
<SelectItem key={idx} value={idx.toString()}>
<div className="flex items-center justify-between gap-4">
<span>{role.service}</span>
<Badge
variant={roleAssigned >= roleNeeded ? "default" : "secondary"}
className="text-xs"
>
{roleAssigned}/{roleNeeded}
</Badge>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{/* Current Position Details */}
<div className="mb-6 p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-slate-900">{currentRole.service}</h4>
<Badge
className={`${
assigned >= needed
? 'bg-green-100 text-green-700'
: 'bg-orange-100 text-orange-700'
} font-semibold`}
>
{assigned}/{needed}
</Badge>
</div>
{currentRole.start_time && (
<div className="flex items-center gap-2 text-sm text-slate-600">
<Clock className="w-4 h-4" />
<span>
{convertTo12Hour(currentRole.start_time)} - {convertTo12Hour(currentRole.end_time)}
</span>
</div>
)}
</div>
{/* Assigned Staff List */}
{assignments.length > 0 && (
<div className="mb-6">
<h4 className="text-sm font-semibold text-slate-700 mb-3">ASSIGNED STAFF:</h4>
<div className="space-y-2">
{assignments.map((assignment, idx) => (
<div
key={idx}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-blue-300 transition-colors"
>
<div className="flex items-center gap-3">
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
<AvatarFallback className="text-white font-bold">
{getInitials(assignment.employee_name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold text-slate-900">{assignment.employee_name}</p>
<p className="text-xs text-slate-500">{currentRole.service}</p>
</div>
</div>
<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>
)}
{/* Add Staff Section */}
{assigned < needed && (
<div>
<h4 className="text-sm font-semibold text-slate-700 mb-3">ADD STAFF:</h4>
{availableStaff.length > 0 ? (
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableStaff.map((staff, idx) => (
<div
key={staff.id}
className="flex items-center justify-between p-3 bg-white rounded-lg border border-slate-200 hover:border-blue-300 transition-colors"
>
<div className="flex items-center gap-3">
<Avatar className={`w-10 h-10 ${avatarColors[idx % avatarColors.length]}`}>
<AvatarFallback className="text-white font-bold">
{getInitials(staff.employee_name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold text-slate-900">{staff.employee_name}</p>
<p className="text-xs text-slate-500">{staff.position || 'Staff Member'}</p>
</div>
</div>
<Button
size="sm"
onClick={() => handleAssignStaff(staff)}
className="bg-blue-600 hover:bg-blue-700"
>
<Plus className="w-4 h-4 mr-1" />
Assign
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-slate-400">
<Users className="w-12 h-12 mx-auto mb-2 opacity-30" />
<p className="text-sm">All available staff have been assigned</p>
</div>
)}
</div>
)}
</div>
<div className="border-t pt-4 flex items-center justify-between">
<div className="flex gap-2">
{selectedShiftIndex > 0 && (
<Button
variant="outline"
onClick={() => setSelectedShiftIndex(selectedShiftIndex - 1)}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Previous
</Button>
)}
{selectedShiftIndex < order.shifts_data.length - 1 && (
<Button
variant="outline"
onClick={() => setSelectedShiftIndex(selectedShiftIndex + 1)}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
)}
</div>
<Button onClick={onClose}>
Done
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,484 @@
import React, { useState, useEffect } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, FileText, Building2, Calendar } from "lucide-react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import ShiftSection from "./ShiftSection";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { format } from "date-fns";
const HUB_OPTIONS = ["Cafeteria A", "Cafeteria B", "Cafeteria C", "Downtown Hub", "Midtown Hub", "Brooklyn Hub"];
const CONTRACT_TYPES = ["W2", "1099", "Temp", "Contract"];
export default function EventForm({ event, onSubmit, isSubmitting, currentUser }) {
const [formData, setFormData] = useState(event || {
event_name: "",
is_recurring: false,
recurrence_type: "single", // single, date_range, scatter
recurrence_start_date: "",
recurrence_end_date: "",
scatter_dates: [],
business_id: "",
business_name: "",
hub: "",
contract_type: "W2",
po_reference: "",
status: "Draft",
date: "",
shifts: [{
shift_name: "Shift 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: 0,
total_value: 0
}]
}],
addons: {
goal: { enabled: false, text: "" },
portal_access: false,
meal_provided: false,
travel_time: false,
tips: { enabled: false, amount: "300/300" }
},
total: 0,
client_name: "",
client_email: "",
client_phone: "",
notes: ""
});
const { data: businesses = [] } = useQuery({
queryKey: ['businesses'],
queryFn: () => base44.entities.Business.list(),
initialData: [],
});
// Auto-fill client details if current user is a client
useEffect(() => {
if (currentUser && currentUser.user_role === "client" && !event) {
// Find client's business
const clientBusiness = businesses.find(b =>
b.email === currentUser.email || b.contact_name === currentUser.full_name
);
if (clientBusiness) {
setFormData(prev => ({
...prev,
business_id: clientBusiness.id,
business_name: clientBusiness.business_name,
client_name: clientBusiness.contact_name || currentUser.full_name,
client_email: clientBusiness.email || currentUser.email,
client_phone: clientBusiness.phone || currentUser.phone,
shifts: prev.shifts.map(shift => ({
...shift,
location_address: clientBusiness.address || shift.location_address
}))
}));
} else {
setFormData(prev => ({
...prev,
client_name: currentUser.full_name,
client_email: currentUser.email,
client_phone: currentUser.phone
}));
}
}
}, [currentUser, businesses, event]);
useEffect(() => {
if (event) {
setFormData(event);
}
}, [event]);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleBusinessChange = (businessId) => {
const selectedBusiness = businesses.find(b => b.id === businessId);
if (selectedBusiness) {
setFormData(prev => ({
...prev,
business_id: businessId,
business_name: selectedBusiness.business_name || "",
client_name: selectedBusiness.contact_name || prev.client_name || "",
client_email: selectedBusiness.email || prev.client_email || "",
client_phone: selectedBusiness.phone || prev.client_phone || "",
shifts: prev.shifts.map(shift => ({
...shift,
location_address: selectedBusiness.address || shift.location_address
}))
}));
}
};
const handleShiftsChange = (shifts) => {
setFormData(prev => ({ ...prev, shifts }));
// Calculate total
const total = shifts.reduce((sum, shift) => {
const shiftTotal = shift.roles.reduce((roleSum, role) => roleSum + (role.total_value || 0), 0);
return sum + shiftTotal;
}, 0);
setFormData(prev => ({ ...prev, total }));
};
const handleAddonsChange = (addons) => {
setFormData(prev => ({ ...prev, addons }));
};
const handleRecurringToggle = (checked) => {
setFormData(prev => ({
...prev,
is_recurring: checked,
recurrence_type: checked ? "date_range" : "single"
}));
};
const handleScatterDateSelect = (date) => {
const dateString = format(date, 'yyyy-MM-dd');
setFormData(prev => {
const currentDates = prev.scatter_dates || [];
const exists = currentDates.includes(dateString);
return {
...prev,
scatter_dates: exists
? currentDates.filter(d => d !== dateString)
: [...currentDates, dateString].sort()
};
});
};
const handleSubmit = (e, isDraft = false) => {
e.preventDefault();
const dataToSubmit = {
...formData,
status: isDraft ? "Draft" : formData.status
};
onSubmit(dataToSubmit);
};
const selectedVendor = currentUser?.user_role === "vendor" ? currentUser?.company_name : formData.vendor_name || formData.business_name;
return (
<form onSubmit={(e) => handleSubmit(e, false)}>
<div className="space-y-6">
{/* One-time vs Recurring Toggle */}
<Card className="border-slate-200">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Label className={`text-base font-semibold ${!formData.is_recurring ? 'text-slate-900' : 'text-slate-400'}`}>
One time event
</Label>
<Switch
checked={formData.is_recurring}
onCheckedChange={handleRecurringToggle}
className="data-[state=checked]:bg-[#0A39DF]"
/>
<Label className={`text-base font-semibold ${formData.is_recurring ? 'text-slate-900' : 'text-slate-400'}`}>
Recurring event
</Label>
</div>
</div>
{/* Recurring Options */}
{formData.is_recurring && (
<div className="mt-6 pt-6 border-t border-slate-200">
<Label className="text-sm font-medium mb-3 block">Recurrence Pattern</Label>
<RadioGroup
value={formData.recurrence_type}
onValueChange={(value) => handleChange('recurrence_type', value)}
className="space-y-3"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="date_range" id="date_range" />
<Label htmlFor="date_range" className="font-normal cursor-pointer">Date Range (Start and End Date)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="scatter" id="scatter" />
<Label htmlFor="scatter" className="font-normal cursor-pointer">Multiple Days (Select Multiple Specific Dates)</Label>
</div>
</RadioGroup>
{/* Date Range Inputs */}
{formData.recurrence_type === "date_range" && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<Label htmlFor="recurrence_start_date" className="text-sm mb-2 block">Start Date</Label>
<Input
id="recurrence_start_date"
type="date"
value={formData.recurrence_start_date || ""}
onChange={(e) => handleChange('recurrence_start_date', e.target.value)}
className="border-slate-200"
/>
</div>
<div>
<Label htmlFor="recurrence_end_date" className="text-sm mb-2 block">End Date</Label>
<Input
id="recurrence_end_date"
type="date"
value={formData.recurrence_end_date || ""}
onChange={(e) => handleChange('recurrence_end_date', e.target.value)}
className="border-slate-200"
/>
</div>
</div>
)}
{/* Scatter Dates Picker */}
{formData.recurrence_type === "scatter" && (
<div className="mt-4">
<Label className="text-sm mb-2 block">Select Multiple Dates</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left">
<Calendar className="w-4 h-4 mr-2" />
{formData.scatter_dates && formData.scatter_dates.length > 0
? `${formData.scatter_dates.length} dates selected`
: "Click to select dates"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CalendarComponent
mode="multiple"
selected={formData.scatter_dates?.map(d => new Date(d)) || []}
onSelect={(dates) => {
setFormData(prev => ({
...prev,
scatter_dates: dates?.map(d => format(d, 'yyyy-MM-dd')).sort() || []
}));
}}
initialFocus
/>
</PopoverContent>
</Popover>
{formData.scatter_dates && formData.scatter_dates.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{formData.scatter_dates.map(date => (
<div key={date} className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm flex items-center gap-2">
{format(new Date(date), 'MMM d, yyyy')}
<button
type="button"
onClick={() => handleScatterDateSelect(new Date(date))}
className="hover:text-red-600"
>
×
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Event Details */}
<Card className="border-slate-200">
<CardContent className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-6">Event</h3>
<div className="space-y-6">
{/* Event Name */}
<div>
<Label htmlFor="event_name" className="text-sm text-slate-600 mb-2 block">Event Name</Label>
<Input
id="event_name"
value={formData.event_name || ""}
onChange={(e) => handleChange('event_name', e.target.value)}
placeholder="Event Name"
required
className="border-slate-200"
/>
</div>
{/* Date and Hub (only for one-time events) */}
{!formData.is_recurring && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="date" className="text-sm text-slate-600 mb-2 block">Date</Label>
<Input
id="date"
type="date"
value={formData.date || ""}
onChange={(e) => handleChange('date', e.target.value)}
required
className="border-slate-200"
/>
</div>
<div>
<Label htmlFor="hub" className="text-sm text-slate-600 mb-2 block">Hub</Label>
<Select value={formData.hub || ""} onValueChange={(value) => handleChange('hub', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Hub Name" />
</SelectTrigger>
<SelectContent>
{HUB_OPTIONS.map(hub => (
<SelectItem key={hub} value={hub}>{hub}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{formData.is_recurring && (
<div>
<Label htmlFor="hub" className="text-sm text-slate-600 mb-2 block">Hub</Label>
<Select value={formData.hub || ""} onValueChange={(value) => handleChange('hub', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Hub Name" />
</SelectTrigger>
<SelectContent>
{HUB_OPTIONS.map(hub => (
<SelectItem key={hub} value={hub}>{hub}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Contract Type and PO Reference */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="contract_type" className="text-sm text-slate-600 mb-2 block">Contract Type</Label>
<Select value={formData.contract_type || "W2"} onValueChange={(value) => handleChange('contract_type', value)}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Contract Type" />
</SelectTrigger>
<SelectContent>
{CONTRACT_TYPES.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="po_reference" className="text-sm text-slate-600 mb-2 block">Purchase Order</Label>
<Input
id="po_reference"
value={formData.po_reference || ""}
onChange={(e) => handleChange('po_reference', e.target.value)}
placeholder="PO reference"
className="border-slate-200"
/>
</div>
</div>
{/* Business Selection (only for vendors) */}
{currentUser?.user_role === "vendor" && (
<div>
<Label htmlFor="business_id" className="text-sm text-slate-600 mb-2 block">Client / Business</Label>
<Select value={formData.business_id || ""} onValueChange={handleBusinessChange}>
<SelectTrigger className="border-slate-200">
<SelectValue placeholder="Select a client">
{formData.business_name && (
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-slate-500" />
<span>{formData.business_name}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{businesses.map((business) => (
<SelectItem key={business.id} value={business.id}>
<div className="flex flex-col">
<span className="font-medium">{business.business_name || "Unnamed Business"}</span>
{business.contact_name && (
<span className="text-xs text-slate-500">{business.contact_name}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Business Display (for clients - read-only) */}
{currentUser?.user_role === "client" && formData.business_name && (
<div>
<Label className="text-sm text-slate-600 mb-2 block">Your Business</Label>
<div className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg border border-slate-200">
<Building2 className="w-5 h-5 text-slate-500" />
<div>
<p className="font-medium text-slate-900">{formData.business_name}</p>
{formData.client_email && (
<p className="text-xs text-slate-500">{formData.client_email}</p>
)}
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Shifts Section */}
<ShiftSection
shifts={formData.shifts || []}
onChange={handleShiftsChange}
addons={formData.addons || {}}
onAddonsChange={handleAddonsChange}
selectedVendor={selectedVendor}
/>
{/* Action Buttons */}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
className="border-slate-300"
onClick={() => window.history.back()}
>
Cancel
</Button>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={(e) => handleSubmit(e, true)}
disabled={isSubmitting}
className="border-slate-300"
>
<FileText className="w-4 h-4 mr-2" />
Draft Event
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-gradient-to-r from-[#1C323E] to-[#0A39DF] hover:from-[#0F1D26] hover:to-[#0829B0] text-white px-8"
>
<Save className="w-4 h-4 mr-2" />
Create Event
</Button>
</div>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,134 @@
import React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Badge } from "@/components/ui/badge";
import { Calendar, MapPin, Users, FileText } from "lucide-react";
import { format, parseISO, isValid } from "date-fns";
const statusColors = {
Draft: "bg-slate-100 text-slate-700 border border-slate-300",
Active: "bg-emerald-50 text-emerald-700 border border-emerald-200",
Pending: "bg-purple-50 text-purple-700 border border-purple-200",
Confirmed: "bg-[#0A39DF]/10 text-[#0A39DF] border border-[#0A39DF]/30",
Assigned: "bg-amber-50 text-amber-700 border border-amber-200",
Completed: "bg-slate-100 text-slate-600 border border-slate-300",
Canceled: "bg-red-50 text-red-700 border border-red-200",
Cancelled: "bg-red-50 text-red-700 border border-red-200"
};
// Helper function to safely format dates
const safeFormatDate = (dateString, formatStr) => {
if (!dateString) return "-";
try {
const date = typeof dateString === 'string' ? parseISO(dateString) : new Date(dateString);
if (!isValid(date)) return "-";
return format(date, formatStr);
} catch {
return "-";
}
};
export default function EventHoverCard({ event, children }) {
const assignedCount = event.assigned_staff?.length || event.assigned || 0;
const requestedCount = event.requested || 0;
const remainingSlots = requestedCount - assignedCount;
return (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
{children}
</HoverCardTrigger>
<HoverCardContent className="w-96 p-0" side="right" align="start">
<div className="bg-gradient-to-br from-slate-50 to-white p-4 border-b border-slate-200">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-bold text-lg text-[#1C323E]">{event.event_name}</h3>
<p className="text-sm text-slate-600">{event.business_name || "Company Name"}</p>
</div>
<Badge className={`${statusColors[event.status]} font-medium`}>
{event.status}
</Badge>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-slate-700">
<Calendar className="w-4 h-4 text-[#0A39DF]" />
<span className="font-semibold">{safeFormatDate(event.date, "MMMM dd, yyyy")}</span>
</div>
{event.event_location && (
<div className="flex items-center gap-2 text-slate-700">
<MapPin className="w-4 h-4 text-[#0A39DF]" />
<span className="truncate">{event.event_location}</span>
</div>
)}
{event.po && (
<div className="flex items-center gap-2 text-slate-700">
<FileText className="w-4 h-4 text-[#0A39DF]" />
<span>PO: {event.po}</span>
</div>
)}
</div>
</div>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-slate-600" />
<span className="text-sm font-medium text-slate-700">Staff Assignment</span>
</div>
<Badge variant="outline" className="border-[#0A39DF] text-[#0A39DF]">
{assignedCount} / {requestedCount}
</Badge>
</div>
{remainingSlots > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 text-xs text-amber-800">
<strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''} still needed
</div>
)}
{remainingSlots === 0 && requestedCount > 0 && (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-2 text-xs text-emerald-800">
Fully staffed
</div>
)}
{event.assigned_staff && event.assigned_staff.length > 0 && (
<div className="space-y-2 max-h-40 overflow-y-auto">
<p className="text-xs font-semibold text-slate-600 uppercase">Assigned Staff:</p>
{event.assigned_staff.map((staff, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<div className="w-6 h-6 bg-[#0A39DF] rounded flex items-center justify-center text-white text-xs font-bold">
{staff.staff_name?.charAt(0) || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 truncate">{staff.staff_name}</p>
{staff.position && <p className="text-xs text-slate-500 truncate">{staff.position}</p>}
</div>
{staff.confirmed && (
<Badge variant="outline" className="text-xs border-emerald-500 text-emerald-700">
Confirmed
</Badge>
)}
</div>
))}
</div>
)}
{event.notes && (
<div className="pt-2 border-t border-slate-200">
<p className="text-xs font-semibold text-slate-600 uppercase mb-1">Notes:</p>
<p className="text-xs text-slate-600">{event.notes}</p>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -0,0 +1,136 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, MoreVertical, Users } from "lucide-react";
import { format } from "date-fns";
import { useNavigate } from "react-router-dom";
import { createPageUrl } from "@/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
const statusColors = {
Active: "bg-blue-100 text-blue-800 border-blue-200",
Pending: "bg-yellow-100 text-yellow-800 border-yellow-200",
Confirmed: "bg-purple-100 text-purple-800 border-purple-200",
Completed: "bg-green-100 text-green-800 border-green-200",
Canceled: "bg-red-100 text-red-800 border-red-200"
};
export default function EventsTable({ events }) {
const navigate = useNavigate();
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Hub</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Date</TableHead>
<TableHead className="font-semibold">Event Name</TableHead>
<TableHead className="font-semibold">PO</TableHead>
<TableHead className="font-semibold text-center">Requested</TableHead>
<TableHead className="font-semibold text-center">Assigned</TableHead>
<TableHead className="font-semibold">Total</TableHead>
<TableHead className="font-semibold text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.length > 0 ? (
events.map((event) => (
<TableRow key={event.id} className="hover:bg-slate-50 transition-colors">
<TableCell className="font-medium">{event.id?.slice(0, 8)}</TableCell>
<TableCell>
<div>
<p className="font-medium text-slate-900">{event.hub}</p>
{event.event_location && (
<p className="text-xs text-slate-500 truncate max-w-xs">{event.event_location}</p>
)}
</div>
</TableCell>
<TableCell>
<Badge className={`${statusColors[event.status]} border font-medium`}>
{event.status}
</Badge>
</TableCell>
<TableCell>
{event.date ? format(new Date(event.date), "MMM d, yyyy") : "-"}
</TableCell>
<TableCell className="font-medium">{event.event_name}</TableCell>
<TableCell>{event.po || "-"}</TableCell>
<TableCell className="text-center">{event.requested || 0}</TableCell>
<TableCell className="text-center">
{event.assigned_staff && event.assigned_staff.length > 0 ? (
<HoverCard>
<HoverCardTrigger asChild>
<button className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800 font-medium">
<Users className="w-4 h-4" />
{event.assigned_staff.length}
</button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-2">
<h4 className="font-semibold text-sm">Assigned Staff:</h4>
<div className="space-y-2">
{event.assigned_staff.map((staff, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold">
{staff.staff_name?.charAt(0)}
</div>
<div>
<p className="font-medium">{staff.staff_name}</p>
{staff.position && (
<p className="text-xs text-slate-500">{staff.position}</p>
)}
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
) : (
<span className="text-slate-400">0</span>
)}
</TableCell>
<TableCell className="font-semibold">${event.total?.toFixed(2) || "0.00"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(createPageUrl(`EditEvent?id=${event.id}`))}>
<Eye className="w-4 h-4 mr-2" />
View Details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={10} className="text-center py-8 text-slate-500">
No events found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Users, Search, Plus, CheckCircle2 } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
export default function QuickAssignPopover({ event, children }) {
const [open, setOpen] = useState(false);
const [selectedStaff, setSelectedStaff] = useState([]);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: allStaff } = useQuery({
queryKey: ['staff'],
queryFn: () => base44.entities.Staff.list(),
initialData: [],
});
const updateEventMutation = useMutation({
mutationFn: ({ id, data }) => base44.entities.Event.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
setSelectedStaff([]);
setOpen(false);
},
});
const requestedCount = event.requested || 0;
const currentAssigned = event.assigned_staff || [];
const assignedCount = currentAssigned.length;
const remainingSlots = requestedCount - assignedCount;
const handleToggleStaff = (staffId) => {
setSelectedStaff(prev => {
if (prev.includes(staffId)) {
return prev.filter(id => id !== staffId);
} else {
// Only allow selection up to remaining slots
if (remainingSlots > 0 && prev.length >= remainingSlots) {
toast({
title: "Selection Limit",
description: `You can only select ${remainingSlots} more staff member${remainingSlots !== 1 ? 's' : ''}`,
variant: "destructive"
});
return prev;
}
return [...prev, staffId];
}
});
};
const handleAssign = () => {
if (selectedStaff.length === 0) return;
if (selectedStaff.length > remainingSlots && remainingSlots > 0) {
toast({
title: "Too Many Selected",
description: `Only ${remainingSlots} slot${remainingSlots !== 1 ? 's' : ''} remaining. Assigning first ${remainingSlots}.`,
variant: "destructive"
});
}
const staffToAssign = remainingSlots > 0
? selectedStaff.slice(0, remainingSlots)
: selectedStaff;
const newAssignments = staffToAssign.map(staffId => {
const staff = allStaff.find(s => s.id === staffId);
return {
staff_id: staff.id,
staff_name: staff.employee_name,
position: staff.position || "",
confirmed: false,
notified: true,
notified_at: new Date().toISOString()
};
});
const updatedAssignedStaff = [...currentAssigned, ...newAssignments];
const newStatus = updatedAssignedStaff.length >= requestedCount ? "Assigned" : "Pending";
updateEventMutation.mutate({
id: event.id,
data: {
...event,
assigned_staff: updatedAssignedStaff,
assigned: updatedAssignedStaff.length,
status: newStatus
}
});
toast({
title: "Staff Assigned",
description: `${newAssignments.length} staff member${newAssignments.length !== 1 ? 's' : ''} assigned successfully`,
});
};
const availableStaff = allStaff.filter(staff =>
!currentAssigned.some(assigned => assigned.staff_id === staff.id)
);
const canSelectMore = remainingSlots === 0 || selectedStaff.length < remainingSlots;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
{children}
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-slate-200 space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm">Quick Assign Staff</h3>
<p className="text-xs text-slate-500">{event.event_name}</p>
</div>
<Badge variant={remainingSlots === 0 ? "default" : "outline"}>
{assignedCount} / {requestedCount}
</Badge>
</div>
{remainingSlots > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
Select up to <strong>{remainingSlots}</strong> staff member{remainingSlots !== 1 ? 's' : ''}
</div>
)}
{remainingSlots === 0 && requestedCount > 0 && (
<div className="bg-green-50 border border-green-200 rounded-lg p-2 text-xs text-green-800 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
<span>Fully staffed - no more assignments needed</span>
</div>
)}
{selectedStaff.length > 0 && (
<Button
onClick={handleAssign}
className="w-full bg-blue-600 hover:bg-blue-700 text-sm"
disabled={updateEventMutation.isPending}
>
<Plus className="w-4 h-4 mr-2" />
Assign {Math.min(selectedStaff.length, remainingSlots || selectedStaff.length)} Staff
</Button>
)}
</div>
<Command>
<CommandInput placeholder="Search staff..." />
<CommandEmpty>No staff found.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{availableStaff.map((staff) => {
const isSelected = selectedStaff.includes(staff.id);
const isDisabled = !canSelectMore && !isSelected && remainingSlots > 0;
return (
<CommandItem
key={staff.id}
onSelect={() => !isDisabled && handleToggleStaff(staff.id)}
className={`cursor-pointer ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={isDisabled}
>
<div className="flex items-center gap-3 w-full">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => !isDisabled && handleToggleStaff(staff.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xs font-bold">
{staff.initial || staff.employee_name?.charAt(0)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-900 truncate text-sm">
{staff.employee_name}
</p>
<div className="flex items-center gap-2 text-xs text-slate-500">
{staff.position && <span>{staff.position}</span>}
{staff.department && (
<>
<span></span>
<span>{staff.department}</span>
</>
)}
</div>
</div>
</div>
</CommandItem>
);
})}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,249 @@
import React, { useState } from "react";
import { base44 } from "@/api/base44Client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Calendar } from "@/components/ui/calendar";
import { Calendar as CalendarIcon, Users, Loader2, CheckCircle2, X } from "lucide-react";
import { useToast } from "@/components/ui/use-toast";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
export default function QuickReorderModal({ event, open, onOpenChange }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedDates, setSelectedDates] = useState([]);
const [formData, setFormData] = useState({
requested: event?.requested || 1,
notes: event?.notes || ""
});
const createEventsMutation = useMutation({
mutationFn: async (ordersData) => {
// Create multiple orders, one for each date
const promises = ordersData.map(orderData =>
base44.entities.Event.create(orderData)
);
return Promise.all(promises);
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['client-events'] });
toast({
title: `${data.length} Order${data.length > 1 ? 's' : ''} Created Successfully! 🎉`,
description: `Your order${data.length > 1 ? 's have' : ' has'} been placed and ${data.length > 1 ? 'are' : 'is'} pending confirmation.`,
});
onOpenChange(false);
setSelectedDates([]);
},
});
const handleDateSelect = (dates) => {
setSelectedDates(dates || []);
};
const handleRemoveDate = (dateToRemove) => {
setSelectedDates(selectedDates.filter(d => d.getTime() !== dateToRemove.getTime()));
};
const handleQuickReorder = () => {
if (selectedDates.length === 0) {
toast({
title: "Date Required",
description: "Please select at least one date for your order",
variant: "destructive"
});
return;
}
// Create an order for each selected date
const ordersData = selectedDates.map(date => ({
event_name: event.event_name,
business_id: event.business_id,
business_name: event.business_name,
hub: event.hub,
event_location: event.event_location,
event_type: event.event_type,
client_name: event.client_name,
client_email: event.client_email,
client_phone: event.client_phone,
client_address: event.client_address,
date: format(date, 'yyyy-MM-dd'),
requested: formData.requested,
notes: formData.notes,
status: "Pending",
assigned: 0,
assigned_staff: []
}));
createEventsMutation.mutate(ordersData);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-full flex items-center justify-center shadow-lg">
<CheckCircle2 className="w-8 h-8 text-white" />
</div>
</div>
<DialogTitle className="text-center text-2xl text-[#1C323E]">Quick Reorder</DialogTitle>
<DialogDescription className="text-center text-base">
Reorder "<strong className="text-[#0A39DF]">{event?.event_name}</strong>" - Select multiple dates
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Event Summary */}
<div className="bg-gradient-to-br from-[#0A39DF]/5 to-[#1C323E]/5 rounded-lg p-4 border border-[#0A39DF]/20">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Service:</span>
<span className="font-semibold text-[#1C323E]">{event?.event_name}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Location:</span>
<span className="font-semibold text-[#1C323E]">{event?.event_location || "Same as before"}</span>
</div>
</div>
</div>
{/* Calendar - Multi Date Selection */}
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
<CalendarIcon className="w-5 h-5 text-[#0A39DF]" />
Select Event Dates * (Click multiple dates)
</Label>
<div className="border-2 border-[#0A39DF]/30 rounded-lg p-4 bg-white">
<Calendar
mode="multiple"
selected={selectedDates}
onSelect={handleDateSelect}
className="rounded-md"
disabled={(date) => date < new Date()}
/>
</div>
{/* Selected Dates Display */}
{selectedDates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-[#1C323E]">
Selected Dates ({selectedDates.length})
</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedDates([])}
className="text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
>
Clear All
</Button>
</div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto p-2 bg-slate-50 rounded-lg border border-slate-200">
{selectedDates.sort((a, b) => a - b).map((date, index) => (
<Badge
key={index}
className="bg-[#0A39DF] text-white px-3 py-1.5 flex items-center gap-2 hover:bg-[#0A39DF]/90"
>
<CalendarIcon className="w-3 h-3" />
{format(date, 'MMM d, yyyy')}
<button
onClick={() => handleRemoveDate(date)}
className="hover:bg-white/20 rounded-full p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
)}
</div>
{/* Staff Count */}
<div className="space-y-2">
<Label htmlFor="requested" className="text-base font-semibold flex items-center gap-2 text-[#1C323E]">
<Users className="w-4 h-4 text-[#0A39DF]" />
Number of Staff Needed
</Label>
<Input
id="requested"
type="number"
min="1"
value={formData.requested}
onChange={(e) => setFormData({ ...formData, requested: parseInt(e.target.value) || 1 })}
className="text-lg font-semibold border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes" className="text-base font-semibold text-[#1C323E]">
Special Instructions (Optional)
</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Any changes or special requests..."
rows={3}
className="border-2 border-[#0A39DF]/30 focus:border-[#0A39DF]"
/>
</div>
{/* Summary */}
{selectedDates.length > 0 && (
<div className="bg-gradient-to-r from-[#0A39DF]/10 to-[#1C323E]/10 rounded-lg p-4 border-2 border-[#0A39DF]/30">
<p className="text-sm font-semibold text-[#1C323E] mb-2">Order Summary:</p>
<ul className="text-sm text-slate-700 space-y-1">
<li> <strong>{selectedDates.length}</strong> order{selectedDates.length > 1 ? 's' : ''} will be created</li>
<li> <strong>{formData.requested}</strong> staff per event</li>
<li> Total: <strong className="text-[#0A39DF]">{selectedDates.length * formData.requested}</strong> staff across all dates</li>
</ul>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={createEventsMutation.isPending}
className="flex-1 border-slate-300"
>
Cancel
</Button>
<Button
onClick={handleQuickReorder}
disabled={createEventsMutation.isPending || selectedDates.length === 0}
className="flex-1 bg-gradient-to-r from-[#0A39DF] to-[#1C323E] hover:from-[#0A39DF]/90 hover:to-[#1C323E]/90 text-white shadow-lg"
>
{createEventsMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating {selectedDates.length} Order{selectedDates.length > 1 ? 's' : ''}...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 mr-2" />
Place {selectedDates.length || 0} Order{selectedDates.length !== 1 ? 's' : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
import React from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { MapPin, Plus } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export default function ShiftCard({ shift, onNotifyStaff }) {
return (
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100 pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{shift.shift_name || "Shift 1"}</CardTitle>
<Button onClick={onNotifyStaff} className="bg-blue-600 hover:bg-blue-700 text-white text-sm">
Notify Staff
</Button>
</div>
<div className="flex items-start gap-6 mt-4">
<div>
<p className="text-xs text-slate-500 mb-2">Managers:</p>
<div className="flex items-center gap-2">
{shift.assigned_staff?.slice(0, 3).map((staff, idx) => (
<div key={idx} className="flex items-center gap-2">
<Avatar className="w-8 h-8 bg-slate-300">
<AvatarFallback className="text-xs">{staff.staff_name?.charAt(0)}</AvatarFallback>
</Avatar>
<div className="text-xs">
<p className="font-medium">{staff.staff_name}</p>
<p className="text-slate-500">{staff.position || "john@email.com"}</p>
</div>
</div>
))}
</div>
</div>
<div className="flex items-start gap-2 text-xs">
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
<div>
<p className="font-medium">Location:</p>
<p className="text-slate-600">{shift.location || "848 East Glen Road New York CA, USA"}</p>
</div>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-slate-50 hover:bg-slate-50">
<TableHead className="text-xs">Unpaid break</TableHead>
<TableHead className="text-xs">Count</TableHead>
<TableHead className="text-xs">Assigned</TableHead>
<TableHead className="text-xs">Uniform type</TableHead>
<TableHead className="text-xs">Price</TableHead>
<TableHead className="text-xs">Amount</TableHead>
<TableHead className="text-xs">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(shift.assigned_staff || []).length > 0 ? (
shift.assigned_staff.map((staff, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs">{shift.unpaid_break || 0}</TableCell>
<TableCell className="text-xs">1</TableCell>
<TableCell className="text-xs">0</TableCell>
<TableCell className="text-xs">{shift.uniform_type || "uniform type"}</TableCell>
<TableCell className="text-xs">${shift.price || 23}</TableCell>
<TableCell className="text-xs">{shift.amount || 120}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="text-xs"></Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-4 text-slate-500 text-xs">
No staff assigned yet
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,426 @@
import React, { useMemo, useState } from "react";
import { base44 } from "@/api/base44Client";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Minus, Trash2, Search, DollarSign, TrendingUp, Check } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const DEPARTMENTS = [
"Accounting", "Operations", "Sales", "HR", "Finance",
"IT", "Marketing", "Customer Service", "Logistics"
];
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
const TIME_OPTIONS = [];
for (let h = 1; h <= 12; h++) {
for (let m of ['00', '30']) {
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
}
}
for (let h = 1; h <= 12; h++) {
for (let m of ['00', '30']) {
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
}
}
export default function ShiftRoleCard({ role, roleIndex, onRoleChange, onDelete, canDelete, selectedVendor }) {
const [roleSearchOpen, setRoleSearchOpen] = useState(false);
// Get current user to check role
const { data: user } = useQuery({
queryKey: ['current-user-shift-role'],
queryFn: () => base44.auth.me(),
});
const userRole = user?.user_role || user?.role;
const isClient = userRole === "client";
// Get client's vendor relationships if client
const { data: clientVendors = [] } = useQuery({
queryKey: ['client-vendors-for-roles', user?.id],
queryFn: async () => {
if (!isClient) return [];
const allEvents = await base44.entities.Event.list();
const clientEvents = allEvents.filter(e =>
e.client_email === user?.email ||
e.business_name === user?.company_name ||
e.created_by === user?.email
);
const vendorNames = new Set();
clientEvents.forEach(event => {
if (event.shifts && Array.isArray(event.shifts)) {
event.shifts.forEach(shift => {
if (shift.roles && Array.isArray(shift.roles)) {
shift.roles.forEach(role => {
if (role.vendor_name) vendorNames.add(role.vendor_name);
});
}
});
}
});
return Array.from(vendorNames);
},
enabled: isClient && !!user,
initialData: [],
});
// Fetch all vendor rates
const { data: allRates = [], isLoading } = useQuery({
queryKey: ['vendor-rates-for-event'],
queryFn: () => base44.entities.VendorRate.list(),
initialData: [],
});
// Filter rates by selected vendor AND client access
const availableRates = useMemo(() => {
if (!allRates || !Array.isArray(allRates)) return [];
let filtered = allRates.filter(r => r && r.is_active);
// If client, only show rates from vendors they work with
if (isClient) {
filtered = filtered.filter(r => {
const hasVendorRelationship = clientVendors.length === 0 || clientVendors.includes(r.vendor_name);
const isVisibleToClient =
r.client_visibility === 'all' ||
(r.client_visibility === 'specific' &&
r.available_to_clients &&
Array.isArray(r.available_to_clients) && // Ensure it's an array before using includes
r.available_to_clients.includes(user?.id));
return hasVendorRelationship && isVisibleToClient;
});
}
// Filter by selected vendor if provided
if (selectedVendor) {
filtered = filtered.filter(r => r.vendor_name === selectedVendor);
}
return filtered;
}, [allRates, selectedVendor, isClient, clientVendors, user?.id]);
// Group rates by category
const ratesByCategory = useMemo(() => {
if (!availableRates || !Array.isArray(availableRates)) return {};
return availableRates.reduce((acc, rate) => {
if (!rate || !rate.category) return acc;
if (!acc[rate.category]) acc[rate.category] = [];
acc[rate.category].push(rate);
return acc;
}, {});
}, [availableRates]);
// Handle role selection from vendor rates
const handleRoleSelect = (rate) => {
if (!rate) return;
onRoleChange('role', rate.role_name || '');
onRoleChange('department', rate.category || '');
onRoleChange('cost_per_hour', rate.client_rate || 0);
onRoleChange('vendor_name', rate.vendor_name || '');
onRoleChange('vendor_id', rate.vendor_id || '');
setRoleSearchOpen(false);
};
// Get selected rate details
const selectedRate = availableRates.find(r => r && r.role_name === role.role);
return (
<Card className="border border-slate-200 bg-white hover:border-[#0A39DF] transition-all">
<CardContent className="p-4">
<div className="grid grid-cols-12 gap-4 items-start">
{/* Row Number */}
<div className="col-span-12 md:col-span-1 flex items-center justify-center">
<div className="w-8 h-8 bg-slate-700 rounded-full flex items-center justify-center text-white font-bold text-sm">
{roleIndex + 1}
</div>
</div>
{/* Role Selection with Vendor Rates - SEARCHABLE */}
<div className="col-span-12 md:col-span-3 space-y-2">
<div>
<Label className="text-xs text-slate-600 mb-1">Service / Role</Label>
<Popover open={roleSearchOpen} onOpenChange={setRoleSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between text-sm h-auto py-2 bg-white hover:bg-slate-50",
!role.role && "text-slate-500"
)}
>
<div className="flex flex-col items-start gap-1 text-left">
{role.role ? (
<>
<span className="font-semibold text-slate-900">{role.role}</span>
{selectedRate && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] bg-blue-50 text-blue-700 border-blue-200">
{selectedRate.category}
</Badge>
<span className="text-xs text-green-600 font-bold">${selectedRate.client_rate}/hr</span>
</div>
)}
</>
) : (
<span>Select service...</span>
)}
</div>
<Search className="w-4 h-4 ml-2 text-slate-400 flex-shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search services..." className="h-9" />
<CommandEmpty>
<div className="p-4 text-center text-sm text-slate-500">
<p>No services found.</p>
{selectedVendor && (
<p className="mt-2 text-xs">Contact {selectedVendor} to add services.</p>
)}
{!selectedVendor && (
<p className="mt-2 text-xs">Select a vendor first to see available services.</p>
)}
</div>
</CommandEmpty>
<div className="max-h-[300px] overflow-y-auto">
{Object.entries(ratesByCategory || {}).map(([category, rates]) => (
<CommandGroup key={category} heading={category} className="text-slate-700">
{Array.isArray(rates) && rates.map((rate) => (
<CommandItem
key={rate.id}
value={`${rate.role_name} ${rate.vendor_name} ${rate.category}`}
onSelect={() => handleRoleSelect(rate)}
className="flex items-center justify-between py-3 cursor-pointer"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-sm text-slate-900">{rate.role_name}</p>
{role.role === rate.role_name && (
<Check className="w-4 h-4 text-[#0A39DF]" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-[10px] bg-slate-50">
{rate.vendor_name}
</Badge>
{rate.pricing_status === 'optimal' && (
<Badge className="bg-green-100 text-green-700 text-[10px] border-0">
Optimal Price
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<div className="text-right">
<p className="text-lg font-bold text-[#0A39DF]">${rate.client_rate}</p>
<p className="text-[10px] text-slate-500">per hour</p>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
))}
</div>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Department (Auto-filled from category) */}
<div>
<Label className="text-xs text-slate-600 mb-1">Department</Label>
<Select value={role.department || ""} onValueChange={(value) => onRoleChange('department', value)}>
<SelectTrigger className="h-9 text-sm bg-white">
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
{DEPARTMENTS.map(dept => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Count */}
<div className="col-span-6 md:col-span-2">
<Label className="text-xs text-slate-600 mb-1 block">Count</Label>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 bg-white hover:bg-slate-50"
onClick={() => onRoleChange('count', Math.max(1, (role.count || 1) - 1))}
>
<Minus className="w-3 h-3" />
</Button>
<Input
type="number"
value={role.count || 1}
onChange={(e) => onRoleChange('count', parseInt(e.target.value) || 1)}
className="w-14 h-9 text-center p-0 text-sm bg-white"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 bg-white hover:bg-slate-50"
onClick={() => onRoleChange('count', (role.count || 1) + 1)}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
{/* Time */}
<div className="col-span-6 md:col-span-3 space-y-2">
<div>
<Label className="text-xs text-slate-600 mb-1">Start Time</Label>
<Select value={role.start_time || "12:00 PM"} onValueChange={(value) => onRoleChange('start_time', value)}>
<SelectTrigger className="h-9 text-sm bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-48">
{TIME_OPTIONS.map(time => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-slate-600 mb-1">End Time</Label>
<Select value={role.end_time || "05:00 PM"} onValueChange={(value) => onRoleChange('end_time', value)}>
<SelectTrigger className="h-9 text-sm bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-48">
{TIME_OPTIONS.map(time => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Hours Badge */}
<div className="col-span-3 md:col-span-1 flex flex-col items-center justify-center">
<Label className="text-xs text-slate-600 mb-1">Hours</Label>
<div className="bg-[#0A39DF] text-white rounded-full w-10 h-10 flex items-center justify-center font-bold text-sm">
{role.hours || 0}
</div>
</div>
{/* Uniform & Break */}
<div className="col-span-9 md:col-span-2 space-y-2">
<div>
<Label className="text-xs text-slate-600 mb-1">Uniform</Label>
<Select value={role.uniform || "Type 1"} onValueChange={(value) => onRoleChange('uniform', value)}>
<SelectTrigger className="h-9 text-sm bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
{UNIFORMS.map(u => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-slate-600 mb-1">Break (min)</Label>
<Input
type="number"
value={role.break_minutes || 30}
onChange={(e) => onRoleChange('break_minutes', parseInt(e.target.value) || 0)}
className="h-9 text-center text-sm bg-white"
placeholder="30"
/>
</div>
</div>
{/* Cost & Value */}
<div className="col-span-12 md:col-span-3 flex items-end justify-between gap-4 pt-4 md:pt-0 border-t md:border-t-0 border-slate-200">
<div className="flex-1">
<Label className="text-xs text-slate-600 mb-1 block">Rate/hr</Label>
<div className="relative">
<DollarSign className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-400" />
<Input
type="number"
value={role.cost_per_hour || 0}
onChange={(e) => onRoleChange('cost_per_hour', parseFloat(e.target.value) || 0)}
className="h-9 text-sm pl-6 bg-white"
placeholder="0.00"
disabled={!!selectedRate}
/>
</div>
{selectedRate && (
<p className="text-[10px] text-green-600 mt-1">From vendor rate card</p>
)}
</div>
<div className="flex-1">
<Label className="text-xs text-slate-600 mb-1 block">Total</Label>
<div className="h-9 flex items-center justify-end font-bold text-[#0A39DF] text-lg">
${(role.total_value || 0).toFixed(2)}
</div>
</div>
{canDelete && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={onDelete}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Pricing Info Bar */}
{selectedRate && (
<div className="mt-3 pt-3 border-t border-slate-200 flex items-center justify-between text-xs bg-slate-50 -mx-4 -mb-4 px-4 py-3 rounded-b-lg">
<div className="flex items-center gap-4">
<span className="text-slate-500">
Employee Wage: <span className="font-semibold text-slate-700">${selectedRate.employee_wage || 0}/hr</span>
</span>
<span className="text-slate-500">
Markup: <span className="font-semibold text-blue-600">{selectedRate.markup_percentage || 0}%</span>
</span>
<span className="text-slate-500">
VA Fee: <span className="font-semibold text-purple-600">{selectedRate.vendor_fee_percentage || 0}%</span>
</span>
</div>
<Badge variant="outline" className="text-[10px] bg-green-50 text-green-700 border-green-200">
Transparent Pricing
</Badge>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,323 @@
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Minus, Pencil, Trash2, Search } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const ROLES = [
"Front Desk",
"Finance",
"Hospitality",
"Recruiter",
"Server",
"Bartender",
"Cook",
"Dishwasher",
"Security",
"Janitor"
];
const DEPARTMENTS = [
"Accounting",
"Operations",
"Sales",
"HR",
"Finance",
"IT",
"Marketing",
"Customer Service",
"Logistics"
];
const UNIFORMS = ["Type 1", "Type 2", "Type 3", "Casual", "Formal"];
const TIME_OPTIONS = [];
for (let h = 1; h <= 12; h++) {
for (let m of ['00', '30']) {
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} AM`);
}
}
for (let h = 1; h <= 12; h++) {
for (let m of ['00', '30']) {
TIME_OPTIONS.push(`${h.toString().padStart(2, '0')}:${m} PM`);
}
}
export default function ShiftRolesTable({ roles, onChange }) {
const handleAddRole = () => {
onChange([...roles, {
role: "",
department: "",
count: 1,
start_time: "12:00 PM",
end_time: "05:00 PM",
hours: 5,
uniform: "Type 1",
break_minutes: 30,
cost_per_hour: 45,
total_value: 0
}]);
};
const handleDeleteRole = (index) => {
onChange(roles.filter((_, i) => i !== index));
};
const handleRoleChange = (index, field, value) => {
const newRoles = [...roles];
newRoles[index] = { ...newRoles[index], [field]: value };
// Calculate hours if times changed
if (field === 'start_time' || field === 'end_time') {
const start = newRoles[index].start_time;
const end = newRoles[index].end_time;
const hours = calculateHours(start, end);
newRoles[index].hours = hours;
}
// Calculate total value
const count = newRoles[index].count || 0;
const hours = newRoles[index].hours || 0;
const cost = newRoles[index].cost_per_hour || 0;
newRoles[index].total_value = count * hours * cost;
onChange(newRoles);
};
const calculateHours = (start, end) => {
// Simple calculation - in production, use proper time library
const startHour = parseInt(start.split(':')[0]) + (start.includes('PM') && !start.startsWith('12') ? 12 : 0);
const endHour = parseInt(end.split(':')[0]) + (end.includes('PM') && !end.startsWith('12') ? 12 : 0);
return Math.max(0, endHour - startHour);
};
return (
<div className="space-y-4">
<div className="border border-slate-200 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="w-12 text-center">#</TableHead>
<TableHead className="min-w-[150px]">Role</TableHead>
<TableHead className="min-w-[130px]">Department</TableHead>
<TableHead className="w-24 text-center">Count</TableHead>
<TableHead className="min-w-[120px]">Start Date</TableHead>
<TableHead className="min-w-[120px]">End Date</TableHead>
<TableHead className="w-20 text-center">Hours</TableHead>
<TableHead className="min-w-[100px]">Uniform</TableHead>
<TableHead className="w-24">Break</TableHead>
<TableHead className="w-20">Cost</TableHead>
<TableHead className="w-28 text-right">Value</TableHead>
<TableHead className="w-24 text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role, index) => (
<TableRow key={index} className="hover:bg-slate-50">
<TableCell className="text-center font-medium text-slate-600">{index + 1}</TableCell>
{/* Role */}
<TableCell>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between">
{role.role || "Role"}
<Search className="w-4 h-4 ml-2 text-slate-400" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="Search role..." />
<CommandEmpty>No role found.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{ROLES.map((r) => (
<CommandItem
key={r}
onSelect={() => handleRoleChange(index, 'role', r)}
>
{r}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</TableCell>
{/* Department */}
<TableCell>
<Select
value={role.department}
onValueChange={(value) => handleRoleChange(index, 'department', value)}
>
<SelectTrigger>
<SelectValue placeholder="Department" />
</SelectTrigger>
<SelectContent>
{DEPARTMENTS.map(dept => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* Count */}
<TableCell>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleRoleChange(index, 'count', Math.max(1, role.count - 1))}
>
<Minus className="w-3 h-3" />
</Button>
<Input
type="number"
value={role.count}
onChange={(e) => handleRoleChange(index, 'count', parseInt(e.target.value) || 1)}
className="w-12 h-8 text-center p-0"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => handleRoleChange(index, 'count', role.count + 1)}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</TableCell>
{/* Start Time */}
<TableCell>
<Select
value={role.start_time}
onValueChange={(value) => handleRoleChange(index, 'start_time', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
{TIME_OPTIONS.map(time => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* End Time */}
<TableCell>
<Select
value={role.end_time}
onValueChange={(value) => handleRoleChange(index, 'end_time', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
{TIME_OPTIONS.map(time => (
<SelectItem key={time} value={time}>{time}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* Hours */}
<TableCell className="text-center font-semibold">{role.hours}</TableCell>
{/* Uniform */}
<TableCell>
<Select
value={role.uniform}
onValueChange={(value) => handleRoleChange(index, 'uniform', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{UNIFORMS.map(u => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* Break */}
<TableCell>
<Input
type="number"
value={role.break_minutes}
onChange={(e) => handleRoleChange(index, 'break_minutes', parseInt(e.target.value) || 0)}
className="w-20 text-center"
placeholder="30"
/>
</TableCell>
{/* Cost */}
<TableCell>
<Input
type="number"
value={role.cost_per_hour}
onChange={(e) => handleRoleChange(index, 'cost_per_hour', parseFloat(e.target.value) || 0)}
className="w-20 text-center"
placeholder="45"
/>
</TableCell>
{/* Value */}
<TableCell className="text-right font-bold text-[#0A39DF]">
${role.total_value?.toFixed(2) || '0.00'}
</TableCell>
{/* Actions */}
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="w-4 h-4 text-slate-600" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleDeleteRole(index)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Add Role Button */}
<Button
type="button"
variant="outline"
onClick={handleAddRole}
className="border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF]"
>
<Plus className="w-4 h-4 mr-2" />
Add Role
</Button>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Users, MapPin } from "lucide-react";
import ShiftRoleCard from "./ShiftRoleCard";
export default function ShiftSection({ shifts = [], onChange, addons = {}, onAddonsChange, selectedVendor }) {
const handleAddShift = () => {
const currentShifts = Array.isArray(shifts) ? shifts : [];
onChange([...currentShifts, {
shift_name: `Shift ${currentShifts.length + 1}`,
shift_contact: "",
location_address: "",
roles: [{
role: "",
department: "",
count: 1,
start_time: "12:00 PM",
end_time: "05:00 PM",
hours: 5,
uniform: "Type 1",
break_minutes: 30,
cost_per_hour: 45,
total_value: 0
}]
}]);
};
const handleDeleteShift = (index) => {
const currentShifts = Array.isArray(shifts) ? shifts : [];
onChange(currentShifts.filter((_, i) => i !== index));
};
const handleShiftChange = (index, field, value) => {
const currentShifts = Array.isArray(shifts) ? shifts : [];
const newShifts = [...currentShifts];
newShifts[index] = { ...newShifts[index], [field]: value };
onChange(newShifts);
};
const handleRolesChange = (shiftIndex, roles) => {
const currentShifts = Array.isArray(shifts) ? shifts : [];
const newShifts = [...currentShifts];
newShifts[shiftIndex] = { ...newShifts[shiftIndex], roles };
onChange(newShifts);
};
const handleAddonToggle = (addon, checked) => {
const currentAddons = addons || {};
onAddonsChange({
...currentAddons,
[addon]: typeof currentAddons[addon] === 'object'
? { ...currentAddons[addon], enabled: checked }
: checked
});
};
const handleAddonTextChange = (addon, text) => {
const currentAddons = addons || {};
onAddonsChange({
...currentAddons,
[addon]: { ...currentAddons[addon], text }
});
};
const safeShifts = Array.isArray(shifts) ? shifts : [];
const safeAddons = addons || {
goal: { enabled: false, text: "" },
portal_access: false,
meal_provided: false,
travel_time: false,
tips: { enabled: false, amount: "300/300" }
};
return (
<div className="space-y-6">
{safeShifts.map((shift, shiftIndex) => {
const safeShift = shift || {};
const safeRoles = Array.isArray(safeShift.roles) ? safeShift.roles : [];
return (
<Card key={shiftIndex} className="border-2 border-slate-200 shadow-md">
<CardContent className="p-6">
{/* Shift Header */}
<div className="flex items-start justify-between mb-6 pb-4 border-b border-slate-200">
<div className="flex items-center gap-3 flex-1">
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-lg">
{shiftIndex + 1}
</div>
<div className="flex-1">
<Input
value={safeShift.shift_name || ""}
onChange={(e) => handleShiftChange(shiftIndex, 'shift_name', e.target.value)}
className="font-bold text-lg border-none p-0 h-auto focus-visible:ring-0 mb-2"
placeholder="Shift Name"
/>
<div className="flex items-center gap-2 text-sm text-slate-500">
<MapPin className="w-4 h-4 flex-shrink-0" />
<Input
value={safeShift.location_address || ""}
onChange={(e) => handleShiftChange(shiftIndex, 'location_address', e.target.value)}
placeholder="1234 Elm Street, Apt 56B, Springfield, IL 62704, USA"
className="border-none p-0 h-auto text-sm focus-visible:ring-0 text-slate-600"
/>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="border-slate-300"
>
<Users className="w-4 h-4 mr-2" />
Add shift contact
</Button>
{safeShifts.length > 1 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleDeleteShift(shiftIndex)}
className="border-red-300 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Shift
</Button>
)}
</div>
</div>
{/* Roles as Cards */}
<div className="space-y-4">
{safeRoles.map((role, roleIndex) => (
<ShiftRoleCard
key={roleIndex}
role={role || {}}
roleIndex={roleIndex}
selectedVendor={selectedVendor}
onRoleChange={(field, value) => {
const newRoles = [...safeRoles];
newRoles[roleIndex] = { ...newRoles[roleIndex], [field]: value };
// Calculate hours if times changed
if (field === 'start_time' || field === 'end_time') {
const start = newRoles[roleIndex].start_time;
const end = newRoles[roleIndex].end_time;
const hours = calculateHours(start, end);
newRoles[roleIndex].hours = hours;
}
// Calculate total value
const count = newRoles[roleIndex].count || 0;
const hours = newRoles[roleIndex].hours || 0;
const cost = newRoles[roleIndex].cost_per_hour || 0;
newRoles[roleIndex].total_value = count * hours * cost;
handleRolesChange(shiftIndex, newRoles);
}}
onDelete={() => {
const newRoles = safeRoles.filter((_, i) => i !== roleIndex);
handleRolesChange(shiftIndex, newRoles);
}}
canDelete={safeRoles.length > 1}
/>
))}
</div>
{/* Add Role Button */}
<Button
type="button"
variant="outline"
onClick={() => {
const newRoles = [...safeRoles, {
role: "",
department: "",
count: 1,
start_time: "12:00 PM",
end_time: "05:00 PM",
hours: 5,
uniform: "Type 1",
break_minutes: 30,
cost_per_hour: 45,
total_value: 0
}];
handleRolesChange(shiftIndex, newRoles);
}}
className="w-full mt-4 border-dashed border-2 border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50"
>
<Plus className="w-4 h-4 mr-2" />
Add Role
</Button>
{/* Shift Total */}
<div className="mt-6 pt-4 border-t border-slate-200 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-600">Shift Total:</span>
<span className="text-2xl font-bold text-[#0A39DF]">
${safeRoles.reduce((sum, r) => sum + ((r && r.total_value) || 0), 0).toFixed(2)}
</span>
</div>
</CardContent>
</Card>
);
})}
{/* Add Location/Shift Button */}
<Button
type="button"
variant="outline"
onClick={handleAddShift}
className="w-full h-16 border-2 border-dashed border-slate-300 hover:border-[#0A39DF] hover:text-[#0A39DF] hover:bg-blue-50 text-base font-semibold"
>
<Plus className="w-5 h-5 mr-2" />
Add Another Location / Shift
</Button>
{/* Other Addons */}
<Card className="border-slate-200 shadow-md">
<CardContent className="p-6">
<h3 className="text-lg font-bold text-slate-900 mb-6">Other Addons</h3>
<div className="space-y-4">
{/* Goal */}
<div className="flex items-center justify-between">
<Label className="text-sm text-slate-700">Goal</Label>
<div className="flex items-center gap-3">
<Switch
checked={safeAddons.goal?.enabled || false}
onCheckedChange={(checked) => handleAddonToggle('goal', checked)}
className="data-[state=checked]:bg-green-500"
/>
{safeAddons.goal?.enabled && (
<Input
value={safeAddons.goal?.text || ""}
onChange={(e) => handleAddonTextChange('goal', e.target.value)}
placeholder="Enter goal"
className="w-64"
/>
)}
</div>
</div>
{/* Portal Access */}
<div className="flex items-center justify-between">
<Label className="text-sm text-slate-700">Portal Access</Label>
<Switch
checked={safeAddons.portal_access || false}
onCheckedChange={(checked) => handleAddonToggle('portal_access', checked)}
className="data-[state=checked]:bg-green-500"
/>
</div>
{/* Meal Provided */}
<div className="flex items-center justify-between">
<Label className="text-sm text-slate-700">Meal Provided</Label>
<Switch
checked={safeAddons.meal_provided || false}
onCheckedChange={(checked) => handleAddonToggle('meal_provided', checked)}
className="data-[state=checked]:bg-green-500"
/>
</div>
{/* Travel Time */}
<div className="flex items-center justify-between">
<Label className="text-sm text-slate-700">Travel Time</Label>
<Switch
checked={safeAddons.travel_time || false}
onCheckedChange={(checked) => handleAddonToggle('travel_time', checked)}
className="data-[state=checked]:bg-green-500"
/>
</div>
{/* Tips */}
<div className="flex items-center justify-between">
<Label className="text-sm text-slate-700">Tips</Label>
<div className="flex items-center gap-3">
<Switch
checked={safeAddons.tips?.enabled || false}
onCheckedChange={(checked) => handleAddonToggle('tips', checked)}
className="data-[state=checked]:bg-green-500"
/>
{safeAddons.tips?.enabled && (
<Input
value={safeAddons.tips?.amount || ""}
onChange={(e) => handleAddonTextChange('tips', e.target.value)}
placeholder="300/300"
className="w-24 text-center"
/>
)}
</div>
</div>
{/* Comments */}
<div>
<Label className="text-sm text-slate-700 mb-2 block">Comments</Label>
<Textarea
placeholder="Add your main text here..."
rows={3}
className="resize-none"
/>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
function calculateHours(start, end) {
if (!start || !end) return 0;
const startHour = parseInt(start.split(':')[0]) + (start.includes('PM') && !start.startsWith('12') ? 12 : 0);
const endHour = parseInt(end.split(':')[0]) + (end.includes('PM') && !end.startsWith('12') ? 12 : 0);
return Math.max(0, endHour - startHour);
}

View File

@@ -0,0 +1,668 @@
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 }) {
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 { 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 - assignedStaff.length;
const isFull = assignedStaff.length >= requestedCount && requestedCount > 0;
// 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 (isFull && requestedCount > 0) {
toast({
title: "Event Fully Staffed",
description: `All ${requestedCount} positions are filled. Cannot select more staff.`,
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) => {
if (isFull && requestedCount > 0) {
toast({
title: "Assignment Limit Reached",
description: `Cannot assign more than ${requestedCount} staff members. All positions are filled.`,
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: "Event Fully Staffed",
description: `All ${requestedCount} positions are filled. Cannot assign more staff.`,
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
{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">
{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>
)}
<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>
</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">
{!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>
)}
<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>
<Button
size="icon"
variant="ghost"
onClick={() => handleRemoveStaff(staff.staff_id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Card } from "@/components/ui/card";
export default function StatusCard({ status, count, percentage, color }) {
const colorClasses = {
blue: "from-[#0A39DF] to-[#0A39DF]/80",
purple: "from-purple-600 to-purple-700",
green: "from-emerald-600 to-emerald-700",
gray: "from-slate-600 to-slate-700",
yellow: "from-amber-500 to-amber-600"
};
return (
<Card className="p-6 bg-white border-slate-200 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-500 mb-2">{status}</p>
<div className={`w-14 h-14 bg-gradient-to-br ${colorClasses[color]} rounded-xl flex items-center justify-center text-white font-bold text-lg shadow-md`}>
{count}
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-[#1C323E]">{percentage}%</div>
<p className="text-xs text-slate-500 mt-1">of total</p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { format } from "date-fns";
import { MessageSquare, Users } from "lucide-react";
export default function ConversationList({ conversations, selectedId, onSelect }) {
const getTypeColor = (type) => {
const colors = {
"client-vendor": "bg-purple-100 text-purple-700",
"staff-client": "bg-blue-100 text-blue-700",
"staff-admin": "bg-slate-100 text-slate-700",
"vendor-admin": "bg-amber-100 text-amber-700",
"client-admin": "bg-green-100 text-green-700",
"group-staff": "bg-indigo-100 text-indigo-700",
"group-event-staff": "bg-pink-100 text-pink-700"
};
return colors[type] || "bg-slate-100 text-slate-700";
};
if (conversations.length === 0) {
return (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 mx-auto text-slate-300 mb-3" />
<p className="text-slate-500">No conversations yet</p>
</div>
);
}
return (
<div className="space-y-2">
{conversations.map((conversation) => {
const isSelected = conversation.id === selectedId;
const otherParticipant = conversation.participants?.[1] || conversation.participants?.[0] || {};
const isGroup = conversation.is_group;
return (
<Card
key={conversation.id}
className={`cursor-pointer transition-all hover:shadow-md ${
isSelected ? 'border-[#0A39DF] border-2 bg-blue-50' : 'border-slate-200'
}`}
onClick={() => onSelect(conversation)}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
{isGroup ? (
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
<Users className="w-5 h-5 text-white" />
</div>
) : (
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-[#0A39DF] text-white font-bold">
{otherParticipant.name?.charAt(0) || '?'}
</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-slate-900 truncate">
{isGroup ? conversation.group_name : conversation.subject || otherParticipant.name || "Conversation"}
</h4>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{isGroup && (
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-200">
<Users className="w-3 h-3 mr-1" />
{conversation.participants?.length || 0} members
</Badge>
)}
<Badge variant="outline" className={`text-xs ${getTypeColor(conversation.conversation_type)}`}>
{conversation.conversation_type?.replace('-', ' → ').replace('group-', '')}
</Badge>
{conversation.related_type && (
<Badge variant="outline" className="text-xs">
{conversation.related_type}
</Badge>
)}
</div>
</div>
{conversation.unread_count > 0 && (
<Badge className="bg-red-500 text-white ml-2">
{conversation.unread_count}
</Badge>
)}
</div>
<p className="text-sm text-slate-600 truncate mt-2">
{conversation.last_message || "No messages yet"}
</p>
{conversation.last_message_at && (
<p className="text-xs text-slate-400 mt-1">
{format(new Date(conversation.last_message_at), "MMM d, h:mm a")}
</p>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Paperclip, Loader2 } from "lucide-react";
import { base44 } from "@/api/base44Client";
export default function MessageInput({ conversationId, onMessageSent, currentUser }) {
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const handleSend = async () => {
if (!message.trim() || sending) return;
setSending(true);
try {
await base44.entities.Message.create({
conversation_id: conversationId,
sender_id: currentUser.id,
sender_name: currentUser.full_name || currentUser.email,
sender_role: currentUser.role || "admin",
content: message.trim(),
read_by: [currentUser.id]
});
await base44.entities.Conversation.update(conversationId, {
last_message: message.trim().substring(0, 100),
last_message_at: new Date().toISOString()
});
setMessage("");
onMessageSent?.();
} catch (error) {
console.error("Failed to send message:", error);
} finally {
setSending(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="border-t border-slate-200 p-4 bg-white">
<div className="flex gap-2">
<Textarea
placeholder="Type your message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
className="min-h-[60px] max-h-[120px]"
disabled={sending}
/>
<div className="flex flex-col gap-2">
<Button
size="icon"
onClick={handleSend}
disabled={!message.trim() || sending}
className="bg-[#0A39DF] hover:bg-[#0A39DF]/90"
>
{sending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
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";
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">
{message.created_date && format(new Date(message.created_date), "MMM d, h:mm a")}
</span>
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
);
}

View File

@@ -0,0 +1,296 @@
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
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { formatDistanceToNow } 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 { data: user } = useQuery({
queryKey: ['current-user-notifications'],
queryFn: () => base44.auth.me(),
});
const { data: notifications = [] } = useQuery({
queryKey: ['activity-logs', user?.id],
queryFn: async () => {
if (!user?.id) return [];
// Create sample notifications if none exist
const existing = await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
if (existing.length === 0 && user?.id) {
// Create initial sample notifications
await base44.entities.ActivityLog.bulkCreate([
{
title: "Event Rescheduled",
description: "Team Meeting was moved to July 15, 3:00 PM",
activity_type: "event_rescheduled",
related_entity_type: "event",
action_label: "View Event",
icon_type: "calendar",
icon_color: "blue",
is_read: false,
user_id: user.id
},
{
title: "Event Canceled",
description: "Product Demo scheduled for May 20 has been canceled",
activity_type: "event_canceled",
related_entity_type: "event",
action_label: "View Event",
icon_type: "calendar",
icon_color: "red",
is_read: false,
user_id: user.id
},
{
title: "Invoice Paid",
description: "You've been added to Client Kickoff on June 8, 10:00 AM",
activity_type: "invoice_paid",
related_entity_type: "invoice",
action_label: "View Invoice",
icon_type: "invoice",
icon_color: "green",
is_read: false,
user_id: user.id
},
{
title: "Staff Selected",
description: "10 staff members selected to fill remaining 10 slots",
activity_type: "staff_assigned",
related_entity_type: "event",
icon_type: "user",
icon_color: "purple",
is_read: true,
user_id: user.id
}
]);
return await base44.entities.ActivityLog.filter({ user_id: user.id }, '-created_date', 50);
}
return existing;
},
enabled: !!user?.id,
initialData: [],
});
const markAsReadMutation = useMutation({
mutationFn: ({ id }) => base44.entities.ActivityLog.update(id, { is_read: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
},
});
const deleteMutation = useMutation({
mutationFn: ({ id }) => base44.entities.ActivityLog.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['activity-logs'] });
},
});
const newNotifications = notifications.filter(n => !n.is_read);
const olderNotifications = notifications.filter(n => n.is_read);
const handleAction = (notification) => {
if (notification.action_link) {
navigate(createPageUrl(notification.action_link));
markAsReadMutation.mutate({ id: notification.id });
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="flex items-center justify-between p-6 border-b border-slate-200">
<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">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center text-white font-bold">
{user?.full_name?.split(' ').map(n => n[0]).join('').slice(0, 2) || 'U'}
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<MoreVertical className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{newNotifications.length > 0 && (
<div className="p-6">
<h3 className="text-sm font-bold text-slate-900 mb-4">New</h3>
<div className="space-y-4">
{newNotifications.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const colorClass = colorMap[notification.icon_color] || colorMap.blue;
return (
<div key={notification.id} className="relative">
<div className="absolute left-0 top-0 w-2 h-2 bg-red-500 rounded-full" />
<div className="flex gap-4 pl-4">
<div className={`w-12 h-12 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h4 className="font-semibold text-slate-900">{notification.title}</h4>
<span className="text-xs text-slate-500 whitespace-nowrap ml-2">
{formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-slate-600 mb-3">{notification.description}</p>
<div className="flex items-center gap-4">
{notification.action_link && (
<button
onClick={() => handleAction(notification)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
>
{notification.action_label || 'View'}
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
onClick={() => markAsReadMutation.mutate({ id: notification.id })}
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Mark as Read
</button>
<button
onClick={() => deleteMutation.mutate({ id: notification.id })}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{olderNotifications.length > 0 && (
<div className="p-6 border-t border-slate-100">
<h3 className="text-sm font-bold text-slate-900 mb-4">Older</h3>
<div className="space-y-4">
{olderNotifications.map((notification) => {
const Icon = iconMap[notification.icon_type] || AlertCircle;
const colorClass = colorMap[notification.icon_color] || colorMap.blue;
return (
<div key={notification.id} className="flex gap-4 opacity-70 hover:opacity-100 transition-opacity">
<div className={`w-12 h-12 rounded-full ${colorClass} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-1">
<h4 className="font-semibold text-slate-900">{notification.title}</h4>
<span className="text-xs text-slate-500 whitespace-nowrap ml-2">
{formatDistanceToNow(new Date(notification.created_date), { addSuffix: true })}
</span>
</div>
<p className="text-sm text-slate-600 mb-3">{notification.description}</p>
<div className="flex items-center gap-4">
{notification.action_link && (
<button
onClick={() => handleAction(notification)}
className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
>
{notification.action_label || 'View'}
<ArrowRight className="w-4 h-4" />
</button>
)}
<button
onClick={() => deleteMutation.mutate({ id: notification.id })}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{notifications.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>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Added AvatarImage
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Users, Calendar, Package, DollarSign, FileText, BarChart3,
Shield, Building2, Briefcase, Info, ExternalLink
} from "lucide-react";
const ROLE_TEMPLATES = {
admin: { name: "Administrator", color: "bg-red-100 text-red-700" },
procurement: { name: "Procurement Manager", color: "bg-purple-100 text-purple-700" },
operator: { name: "Operator", color: "bg-blue-100 text-blue-700" },
sector: { name: "Sector Manager", color: "bg-cyan-100 text-cyan-700" },
client: { name: "Client", color: "bg-green-100 text-green-700" },
vendor: { name: "Vendor Partner", color: "bg-amber-100 text-amber-700" },
workforce: { name: "Workforce Member", color: "bg-slate-100 text-slate-700" }
};
const ROLE_PERMISSIONS = {
admin: [
{ id: "system_admin", icon: Shield, name: "System Administration", description: "Full access to all system settings and user management", defaultEnabled: true },
{ id: "enterprise_mgmt", icon: Building2, name: "Enterprise Management", description: "Create and manage enterprises, sectors, and partners", defaultEnabled: true },
{ id: "vendor_oversight", icon: Package, name: "Vendor Oversight", description: "Approve/suspend vendors and manage all vendor relationships", defaultEnabled: true },
{ id: "financial_admin", icon: DollarSign, name: "Financial Administration", description: "Access all financial data and process payments", defaultEnabled: true },
{ id: "reports_admin", icon: BarChart3, name: "Admin Reports", description: "Generate and export all system reports", defaultEnabled: true },
],
procurement: [
{ id: "vendor_view", icon: Package, name: "View All Vendors", description: "Access complete vendor directory and profiles", defaultEnabled: true },
{ id: "vendor_onboard", icon: Users, name: "Onboard Vendors", description: "Add new vendors to the platform", defaultEnabled: true },
{ id: "vendor_compliance", icon: Shield, name: "Vendor Compliance Review", description: "Review and approve vendor compliance documents", defaultEnabled: true },
{ id: "rate_cards", icon: DollarSign, name: "Rate Card Management", description: "Create, edit, and approve vendor rate cards", defaultEnabled: true },
{ id: "vendor_performance", icon: BarChart3, name: "Vendor Performance Analytics", description: "View scorecards and KPI reports", defaultEnabled: true },
{ id: "order_oversight", icon: Calendar, name: "Order Oversight", description: "View and manage all orders across sectors", defaultEnabled: false },
],
operator: [
{ id: "enterprise_events", icon: Calendar, name: "Enterprise Event Management", description: "Create and manage events across your enterprise", defaultEnabled: true },
{ id: "sector_mgmt", icon: Building2, name: "Sector Management", description: "Configure and manage your sectors", defaultEnabled: true },
{ id: "staff_mgmt", icon: Users, name: "Workforce Management", description: "Assign and manage staff across enterprise", defaultEnabled: true },
{ id: "event_financials", icon: DollarSign, name: "Event Financials", description: "View costs and billing for all events", defaultEnabled: true },
{ id: "approve_events", icon: Shield, name: "Approve Events", description: "Approve event requests from sectors", defaultEnabled: false },
{ id: "enterprise_reports", icon: BarChart3, name: "Enterprise Reports", description: "Generate analytics for your enterprise", defaultEnabled: true },
],
sector: [
{ id: "sector_events", icon: Calendar, name: "Sector Event Management", description: "Create and manage events at your location", defaultEnabled: true },
{ id: "location_staff", icon: Users, name: "Location Staff Management", description: "Schedule and manage staff at your sector", defaultEnabled: true },
{ id: "timesheet_approval", icon: FileText, name: "Timesheet Approval", description: "Review and approve staff timesheets", defaultEnabled: true },
{ id: "vendor_rates", icon: DollarSign, name: "View Vendor Rates", description: "Access rate cards for approved vendors", defaultEnabled: true },
{ id: "event_costs", icon: BarChart3, name: "Event Cost Visibility", description: "View billing for your events", defaultEnabled: false },
],
client: [
{ id: "create_orders", icon: Calendar, name: "Create Orders", description: "Request staffing for events", defaultEnabled: true },
{ id: "view_orders", icon: FileText, name: "View My Orders", description: "Track your event orders", defaultEnabled: true },
{ id: "vendor_selection", icon: Package, name: "Vendor Selection", description: "View and request specific vendors", defaultEnabled: true },
{ id: "staff_review", icon: Users, name: "Staff Review", description: "Rate staff and request changes", defaultEnabled: true },
{ id: "invoices", icon: DollarSign, name: "Billing & Invoices", description: "View and download invoices", defaultEnabled: true },
{ id: "spend_analytics", icon: BarChart3, name: "Spend Analytics", description: "View your spending trends", defaultEnabled: false },
],
vendor: [
{ id: "order_fulfillment", icon: Calendar, name: "Order Fulfillment", description: "Accept and manage assigned orders", defaultEnabled: true },
{ id: "my_workforce", icon: Users, name: "My Workforce", description: "Manage your staff members", defaultEnabled: true },
{ id: "staff_compliance", icon: Shield, name: "Staff Compliance", description: "Track certifications and background checks", defaultEnabled: true },
{ id: "my_rates", icon: DollarSign, name: "Rate Management", description: "View and propose rate cards", defaultEnabled: true },
{ id: "my_invoices", icon: FileText, name: "Invoices & Payments", description: "Create and track invoices", defaultEnabled: true },
{ id: "performance", icon: BarChart3, name: "Performance Dashboard", description: "View your scorecard and metrics", defaultEnabled: false },
],
workforce: [
{ id: "my_schedule", icon: Calendar, name: "View My Schedule", description: "See upcoming shifts and assignments", defaultEnabled: true },
{ id: "clock_inout", icon: FileText, name: "Clock In/Out", description: "Record shift start and end times", defaultEnabled: true },
{ id: "my_profile", icon: Users, name: "My Profile", description: "Update contact info and availability", defaultEnabled: true },
{ id: "upload_certs", icon: Shield, name: "Upload Certifications", description: "Add certificates and licenses", defaultEnabled: true },
{ id: "earnings", icon: DollarSign, name: "View Earnings", description: "See pay, hours, and payment history", defaultEnabled: true },
{ id: "performance_stats", icon: BarChart3, name: "My Performance", description: "View ratings and reliability metrics", defaultEnabled: false },
]
};
export default function UserPermissionsModal({ user, open, onClose, onSave, isSaving }) {
const [selectedRole, setSelectedRole] = useState(user?.user_role || "client");
const [permissions, setPermissions] = useState({});
// Initialize permissions when user or role changes
useEffect(() => {
if (user && open) {
const rolePerms = ROLE_PERMISSIONS[user.user_role || "client"] || [];
const initialPerms = {};
rolePerms.forEach(perm => {
initialPerms[perm.id] = perm.defaultEnabled;
});
setPermissions(initialPerms);
setSelectedRole(user.user_role || "client");
}
}, [user, open]);
// Update permissions when role changes
useEffect(() => {
const rolePerms = ROLE_PERMISSIONS[selectedRole] || [];
const newPerms = {};
rolePerms.forEach(perm => {
newPerms[perm.id] = perm.defaultEnabled;
});
setPermissions(newPerms);
}, [selectedRole]);
const handleToggle = (permId) => {
setPermissions(prev => ({
...prev,
[permId]: !prev[permId]
}));
};
const handleSave = () => {
onSave({
...user,
user_role: selectedRole,
permissions: Object.keys(permissions).filter(key => permissions[key])
});
onClose();
};
if (!user) return null;
const roleTemplate = ROLE_TEMPLATES[selectedRole];
const rolePermissions = ROLE_PERMISSIONS[selectedRole] || [];
const userInitial = user.full_name?.charAt(0).toUpperCase() || user.email?.charAt(0).toUpperCase() || "U";
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle className="text-xl font-bold text-slate-900">User Permissions</DialogTitle>
</div>
</DialogHeader>
{/* User Info */}
<div className="flex items-center justify-between py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<Avatar className="w-14 h-14 border-2 border-slate-200">
<AvatarImage src={user.profile_picture} alt={user.full_name} />
<AvatarFallback className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] text-white font-bold text-lg">
{userInitial}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-lg text-slate-900">{user.full_name}</h3>
<p className="text-sm text-slate-500">{user.email}</p>
</div>
</div>
<Button variant="link" className="text-[#0A39DF] text-sm">
View profile <ExternalLink className="w-3 h-3 ml-1" />
</Button>
</div>
{/* Info Alert */}
<Alert className="bg-blue-50 border-blue-200">
<Info className="w-4 h-4 text-blue-600" />
<AlertDescription className="text-blue-800 text-sm">
Permission list will change when you select a different user group
</AlertDescription>
</Alert>
{/* User Group Selector */}
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">User Group</label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger className="border-slate-300">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ROLE_TEMPLATES).map(([key, role]) => (
<SelectItem key={key} value={key}>
<div className="flex items-center gap-2">
<Badge className={role.color + " text-xs"}>{role.name}</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Permissions List */}
<div className="space-y-3 py-4">
{rolePermissions.map((perm) => {
const Icon = perm.icon;
return (
<div
key={perm.id}
className="flex items-center justify-between p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
>
<div className="flex items-center gap-4 flex-1">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center shadow-sm border border-slate-200">
<Icon className="w-5 h-5 text-[#0A39DF]" />
</div>
<div className="flex-1">
<h4 className="font-semibold text-sm text-slate-900">{perm.name}</h4>
<p className="text-xs text-slate-500 mt-0.5">{perm.description}</p>
</div>
</div>
<Switch
checked={permissions[perm.id] || false}
onCheckedChange={() => handleToggle(perm.id)}
className="data-[state=checked]:bg-emerald-500"
/>
</div>
);
})}
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-slate-200">
<Button onClick={handleSave} className="bg-[#0A39DF] hover:bg-[#0A39DF]/90 px-6">
Save changes
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
import React, { useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Shield, Download, Upload, Save, AlertCircle } from "lucide-react";
export default function COIViewer({ vendor, onClose }) {
const coverageLines = [
{
name: "General Liability",
expires: "11/20/2025",
status: "Non-Compliant",
statusColor: "bg-red-100 text-red-700"
},
{
name: "Automobile Liability",
expires: "03/30/2025",
status: "Non-Compliant",
statusColor: "bg-red-100 text-red-700"
},
{
name: "Workers Compensation",
expires: "11/15/2025",
status: "Non-Compliant",
statusColor: "bg-red-100 text-red-700"
},
{
name: "Property",
expires: "",
status: "Non-Compliant",
statusColor: "bg-red-100 text-red-700"
}
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
<div>
<div className="flex items-center gap-2 mb-1">
<Shield className="w-5 h-5 text-[#0A39DF]" />
<h3 className="font-bold text-lg text-[#1C323E]">Policy & Agent</h3>
</div>
<p className="text-xs text-slate-500">Last Reviewed: 07/25/2025, 12:24 PM</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Upload className="w-4 h-4 mr-2" />
Upload COI
</Button>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Lines Covered by Agent */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100">
<CardTitle className="text-base text-[#1C323E]">Lines Covered by Agent</CardTitle>
<p className="text-sm text-blue-600 mt-2">
Legendary Event Staffing & Entertainment, LLC (Vendor as an Agent)
</p>
<p className="text-xs text-slate-500 mt-1">Uploaded 07/23/2025, 01:20 PM</p>
</CardHeader>
<CardContent className="p-6 space-y-4">
{coverageLines.map((line, idx) => (
<div key={idx} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<div>
<p className="font-semibold text-[#1C323E] text-sm">{line.name}</p>
{line.expires && (
<p className="text-xs text-slate-600 mt-1">Expires {line.expires}</p>
)}
</div>
<Badge className={line.statusColor}>
{line.status}
</Badge>
</div>
))}
<Button variant="link" className="text-blue-600 text-sm p-0 h-auto mt-4">
Show Iliana contact info
</Button>
</CardContent>
</Card>
{/* Non-Compliant Notes */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-amber-50 to-white border-b border-slate-100">
<CardTitle className="text-base text-[#1C323E] flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-600" />
Non-Compliant Notes
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* General Liability */}
<div>
<h4 className="font-semibold text-[#1C323E] mb-3">General Liability</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Please confirm aggregate limit applies on a per location basis on the certificate and/or by uploading additional documentation.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Waiver of Subrogation</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Primary and Non-Contributory</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Confirm on certificate Contractual Liability is not excluded.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Confirm on the certificate that severability of interest is included.</span>
</li>
</ul>
</div>
{/* Property */}
<div>
<h4 className="font-semibold text-[#1C323E] mb-3">Property</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Please submit proof of coverage.</span>
</li>
</ul>
</div>
{/* Additional Coverage */}
<div>
<h4 className="font-semibold text-[#1C323E] mb-3">Additional Coverage</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Waiver of Subrogation applies to: South Bay Construction and Development I, LLC and South Bay Development Company, and their Employees, Agents</span>
</li>
</ul>
</div>
{/* Automobile Liability */}
<div>
<h4 className="font-semibold text-[#1C323E] mb-3">Automobile Liability</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Waiver of Subrogation</span>
</li>
</ul>
</div>
{/* Workers Compensation */}
<div>
<h4 className="font-semibold text-[#1C323E] mb-3">Workers Compensation</h4>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex items-start gap-2">
<span className="text-amber-600 mt-1"></span>
<span>Waiver of Subrogation</span>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
{/* Actions */}
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="w-3 h-3 mr-1" />
4 Items Non-Compliant
</Badge>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90">
<Save className="w-4 h-4 mr-2" />
Save & Send for Review
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,650 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Building2,
Award,
Shield,
FileText,
DollarSign,
TrendingUp,
Users,
MapPin,
Mail,
Phone,
Edit,
Download,
CheckCircle2,
AlertCircle,
Clock,
Target,
Upload,
ArrowLeft // Added for viewer components
} from "lucide-react";
// COI Viewer Component
const COIViewer = ({ vendor, onClose }) => (
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100 flex-row items-center justify-between py-4 px-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full">
<ArrowLeft className="w-5 h-5 text-slate-600" />
</Button>
<CardTitle className="text-base text-[#1C323E]">
Certificate of Insurance for {vendor.name}
</CardTitle>
</div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download COI
</Button>
</CardHeader>
<CardContent className="p-6 h-[500px] overflow-y-auto">
{/* Mock PDF Viewer / Content */}
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg p-4 text-gray-500 border border-gray-200">
<FileText className="w-16 h-16 mb-4" />
<p className="text-lg font-semibold mb-2">COI Document Preview</p>
<p className="text-sm text-center">This is a placeholder for the Certificate of Insurance document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.</p>
<p className="text-xs mt-4">Vendor Name: <span className="font-medium text-slate-700">{vendor.name}</span></p>
<p className="text-xs">Policy Number: <span className="font-medium text-slate-700">ABC-123456789</span></p>
<p className="text-xs">Expiration Date: <span className="font-medium text-slate-700">11/20/2025</span></p>
<div className="mt-6 w-full max-w-lg h-[300px] bg-white border border-gray-300 flex items-center justify-center text-gray-400 text-sm rounded-md shadow-sm">
[PDF Viewer Embed / Document Image Placeholder]
</div>
</div>
</CardContent>
</Card>
);
// W9 Form Viewer Component
const W9FormViewer = ({ vendor, onClose }) => (
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-blue-50 to-white border-b border-slate-100 flex-row items-center justify-between py-4 px-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onClose} className="rounded-full">
<ArrowLeft className="w-5 h-5 text-slate-600" />
</Button>
<CardTitle className="text-base text-[#1C323E]">
W-9 Form for {vendor.name}
</CardTitle>
</div>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download W-9
</Button>
</CardHeader>
<CardContent className="p-6 h-[500px] overflow-y-auto">
{/* Mock PDF Viewer / Content */}
<div className="flex flex-col items-center justify-center h-full bg-gray-100 rounded-lg p-4 text-gray-500 border border-gray-200">
<FileText className="w-16 h-16 mb-4" />
<p className="text-lg font-semibold mb-2">W-9 Form Document Preview</p>
<p className="text-sm text-center">This is a placeholder for the W-9 Form document viewer. In a real application, an embedded PDF viewer or a document image would be displayed here.</p>
<p className="text-xs mt-4">Vendor Name: <span className="font-medium text-slate-700">{vendor.name}</span></p>
<p className="text-xs">Tax ID: <span className="font-medium text-slate-700">XX-XXXXXXX</span></p>
<p className="text-xs">Last Updated: <span className="font-medium text-slate-700">07/23/2025</span></p>
<div className="mt-6 w-full max-w-lg h-[300px] bg-white border border-gray-300 flex items-center justify-center text-gray-400 text-sm rounded-md shadow-sm">
[PDF Viewer Embed / Document Image Placeholder]
</div>
</div>
</CardContent>
</Card>
);
export default function VendorDetailModal({ vendor, open, onClose, onEdit }) {
const [activeTab, setActiveTab] = useState("overview");
const [showCOI, setShowCOI] = useState(false);
const [showW9, setShowW9] = useState(false);
if (!vendor) return null;
// Mock compliance data - would come from backend
const complianceData = {
overall: vendor.name === "Legendary Event Staffing" ? 98 : 95,
coi: { status: "valid", expires: "11/20/2025" },
w9: { status: "valid", lastUpdated: "07/23/2025" },
backgroundChecks: { status: "valid", percentage: 100 },
insurance: { status: vendor.name === "Legendary Event Staffing" ? "valid" : "expiring", expires: "03/30/2025" }
};
const performanceData = {
overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
fillRate: vendor.fillRate,
onTimeRate: vendor.onTimeRate,
billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
clientSatisfaction: vendor.csat,
reliability: vendor.name === "Legendary Event Staffing" ? 98 : 91,
avgHourlyRate: vendor.name === "Legendary Event Staffing" ? 23.50 : vendor.name === "Instawork" ? 22.00 : 21.00,
vendorFeePercentage: vendor.name === "Legendary Event Staffing" ? 19.6 : vendor.name === "Instawork" ? 20.5 : 22.0,
employeeWage: vendor.name === "Legendary Event Staffing" ? 18.50 : 17.00,
totalEmployees: vendor.employees,
monthlySpend: vendor.spend
};
const documents = [
{ name: "COI (Certificate of Insurance)", status: complianceData.coi.status, lastUpdated: "2 days ago", type: "coi" },
{ name: "W9 Forms", status: complianceData.w9.status, lastUpdated: "2 days ago", type: "w9" },
{ name: "Contracts", status: "active", lastUpdated: "2 days ago", type: "contract" },
{ name: "ESG Certification", status: "valid", lastUpdated: "2 days ago", type: "esg" },
{ name: "Policies", status: "updated", lastUpdated: "2 days ago", type: "policy" },
{ name: "Forms & Templates", status: "available", lastUpdated: "2 days ago", type: "forms" }
];
const getScoreColor = (score) => {
if (score >= 97) return "text-green-600";
if (score >= 90) return "text-blue-600";
if (score >= 80) return "text-yellow-600";
return "text-red-600";
};
const getStatusBadge = (status) => {
const colors = {
valid: "bg-green-100 text-green-700",
active: "bg-blue-100 text-blue-700",
updated: "bg-blue-100 text-blue-700",
available: "bg-slate-100 text-slate-700",
expiring: "bg-yellow-100 text-yellow-700",
expired: "bg-red-100 text-red-700"
};
return colors[status] || "bg-gray-100 text-gray-700";
};
const getScoreBadgeColor = (score) => {
if (score >= 95) return "bg-green-100 text-green-700 border-green-200";
if (score >= 90) return "bg-blue-100 text-blue-700 border-blue-200";
if (score >= 85) return "bg-amber-100 text-amber-700 border-amber-200";
return "bg-red-100 text-red-700 border-red-200";
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="p-0">
<div className="flex items-start justify-between px-6 pt-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-[#0A39DF] to-[#1C323E] rounded-xl flex items-center justify-center">
<Building2 className="w-8 h-8 text-white" />
</div>
<div>
<DialogTitle className="text-2xl">{vendor.name}</DialogTitle>
<div className="flex items-center gap-3 mt-2">
<Badge className="bg-blue-100 text-blue-700">
{vendor.region}
</Badge>
<Badge variant="outline">{vendor.specialty}</Badge>
{vendor.softwareType === "platform" && (
<Badge className="bg-green-100 text-green-700"> Full Platform</Badge>
)}
{vendor.softwareType === "building" && (
<Badge className="bg-blue-100 text-blue-700"> Building Platform</Badge>
)}
</div>
</div>
</div>
<Button variant="outline" onClick={() => onEdit(vendor)}>
<Edit className="w-4 h-4 mr-2" />
Edit
</Button>
</div>
</DialogHeader>
{/* Tabs Navigation */}
<div className="border-b border-slate-200 bg-white px-6">
<div className="flex gap-1">
<button
onClick={() => { setActiveTab("overview"); setShowCOI(false); setShowW9(false); }}
className={`px-4 py-3 text-sm font-medium transition-all ${
activeTab === "overview"
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
Overview
</button>
<button
onClick={() => { setActiveTab("compliance"); setShowCOI(false); setShowW9(false); }}
className={`px-4 py-3 text-sm font-medium transition-all ${
activeTab === "compliance"
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
Compliance
</button>
<button
onClick={() => { setActiveTab("performance"); setShowCOI(false); setShowW9(false); }}
className={`px-4 py-3 text-sm font-medium transition-all ${
activeTab === "performance"
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
Performance
</button>
<button
onClick={() => { setActiveTab("documents"); setShowCOI(false); setShowW9(false); }}
className={`px-4 py-3 text-sm font-medium transition-all ${
activeTab === "documents"
? "text-[#0A39DF] border-b-2 border-[#0A39DF] bg-blue-50"
: "text-slate-600 hover:text-slate-900 hover:bg-slate-50"
}`}
>
Documents
</button>
</div>
</div>
{/* Tab Content */}
<div className="overflow-y-auto max-h-[calc(90vh-180px)] bg-slate-50 p-6">
{/* Key Metrics Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<Award className="w-5 h-5 text-amber-600" />
<span className={`text-2xl font-bold ${performanceData.overallScore === 'A+' ? 'text-green-600' : 'text-blue-600'}`}>
{performanceData.overallScore}
</span>
</div>
<p className="text-xs text-slate-600 font-medium">Overall Score</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<Shield className="w-5 h-5 text-green-600" />
<span className={`text-2xl font-bold ${getScoreColor(complianceData.overall)}`}>
{complianceData.overall}%
</span>
</div>
<p className="text-xs text-slate-600 font-medium">Compliance</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<DollarSign className="w-5 h-5 text-blue-600" />
<span className={`text-2xl font-bold ${getScoreColor(performanceData.billingAccuracy)}`}>
{performanceData.billingAccuracy}%
</span>
</div>
<p className="text-xs text-slate-600 font-medium">Billing Accuracy</p>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<TrendingUp className="w-5 h-5 text-emerald-600" />
<span className={`text-2xl font-bold ${getScoreColor(performanceData.fillRate)}`}>
{performanceData.fillRate}%
</span>
</div>
<p className="text-xs text-slate-600 font-medium">Fill Rate</p>
</CardContent>
</Card>
</div>
{/* Overview Tab Content */}
{activeTab === "overview" && (
<div className="space-y-4">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-base">Company Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">Total Staff</p>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-slate-400" />
<span className="font-semibold">{vendor.employees.toLocaleString()}</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Region</p>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-slate-400" />
<span className="font-semibold">{vendor.region}, {vendor.state}</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Email</p>
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-400" />
<span className="font-semibold text-sm">contact@{vendor.name.toLowerCase().replace(/\s+/g, '')}.com</span>
</div>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">Phone</p>
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-slate-400" />
<span className="font-semibold">(555) 123-4567</span>
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-200">
<p className="text-xs text-slate-500 mb-2">Technology Stack</p>
<div className="flex items-center gap-2">
{vendor.softwareType === "platform" && (
<Badge className="bg-green-100 text-green-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "building" && (
<Badge className="bg-blue-100 text-blue-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "partial" && (
<Badge className="bg-yellow-100 text-yellow-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "traditional" && (
<Badge className="bg-gray-100 text-gray-700">
{vendor.software}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Compliance Tab Content */}
{activeTab === "compliance" && (
<div className="space-y-4">
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-green-50 to-white border-b border-slate-100">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Shield className="w-5 h-5 text-green-600" />
Compliance Status
</CardTitle>
<Badge className="bg-green-100 text-green-700 text-lg px-4 py-1">
{complianceData.overall}%
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-slate-900">COI</p>
<Badge className={getStatusBadge(complianceData.coi.status)}>
{complianceData.coi.status}
</Badge>
</div>
<p className="text-xs text-slate-600">Expires: {complianceData.coi.expires}</p>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-slate-900">W-9 Form</p>
<Badge className={getStatusBadge(complianceData.w9.status)}>
{complianceData.w9.status}
</Badge>
</div>
<p className="text-xs text-slate-600">Updated: {complianceData.w9.lastUpdated}</p>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-slate-900">Background Checks</p>
<Badge className="bg-green-100 text-green-700">
{complianceData.backgroundChecks.percentage}%
</Badge>
</div>
<p className="text-xs text-slate-600">All staff verified</p>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-semibold text-slate-900">Insurance</p>
<Badge className={getStatusBadge(complianceData.insurance.status)}>
{complianceData.insurance.status}
</Badge>
</div>
<p className="text-xs text-slate-600">Expires: {complianceData.insurance.expires}</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Performance Tab Content */}
{activeTab === "performance" && (
<div className="space-y-6">
{/* Company Size & Financial Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-slate-200 bg-gradient-to-br from-blue-50 to-white">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-6 h-6 text-blue-600" />
</div>
<div>
<p className="text-xs text-slate-500">Total Employees</p>
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.totalEmployees.toLocaleString()}</p>
</div>
</div>
<p className="text-xs text-slate-600">Available workforce</p>
</CardContent>
</Card>
<Card className="border-slate-200 bg-gradient-to-br from-emerald-50 to-white">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-emerald-100 rounded-lg flex items-center justify-center">
<DollarSign className="w-6 h-6 text-emerald-600" />
</div>
<div>
<p className="text-xs text-slate-500">Monthly Spend</p>
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.monthlySpend}</p>
</div>
</div>
<p className="text-xs text-slate-600">Average monthly cost</p>
</CardContent>
</Card>
<Card className="border-slate-200 bg-gradient-to-br from-purple-50 to-white">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<Award className="w-6 h-6 text-purple-600" />
</div>
<div>
<p className="text-xs text-slate-500">Performance Grade</p>
<p className="text-2xl font-bold text-[#1C323E]">{performanceData.overallScore}</p>
</div>
</div>
<p className="text-xs text-slate-600">Overall rating</p>
</CardContent>
</Card>
</div>
{/* Rates & Fees Breakdown */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-base flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#0A39DF]" />
Rates & Fee Structure
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-xs text-slate-500 mb-2">Average Hourly Rate</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-[#0A39DF]">${performanceData.avgHourlyRate.toFixed(2)}</p>
<p className="text-sm text-slate-600">/hr</p>
</div>
<p className="text-xs text-slate-600 mt-2">Client billing rate</p>
</div>
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-xs text-slate-500 mb-2">Employee Wage</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-emerald-600">${performanceData.employeeWage.toFixed(2)}</p>
<p className="text-sm text-slate-600">/hr</p>
</div>
<p className="text-xs text-slate-600 mt-2">Worker take-home rate</p>
</div>
<div className="p-5 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-xs text-slate-500 mb-2">Vendor Fee</p>
<div className="flex items-baseline gap-2">
<p className="text-3xl font-bold text-purple-600">{performanceData.vendorFeePercentage}%</p>
</div>
<p className="text-xs text-slate-600 mt-2">${(performanceData.avgHourlyRate - performanceData.employeeWage).toFixed(2)}/hr markup</p>
</div>
</div>
{/* Visual breakdown */}
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm font-semibold text-[#1C323E] mb-3">Cost Breakdown</p>
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 bg-slate-200 rounded-full h-8 overflow-hidden flex">
<div
className="bg-emerald-500 flex items-center justify-center text-xs font-semibold text-white"
style={{ width: `${(performanceData.employeeWage / performanceData.avgHourlyRate) * 100}%` }}
>
${performanceData.employeeWage}
</div>
<div
className="bg-purple-500 flex items-center justify-center text-xs font-semibold text-white"
style={{ width: `${((performanceData.avgHourlyRate - performanceData.employeeWage) / performanceData.avgHourlyRate) * 100}%` }}
>
${(performanceData.avgHourlyRate - performanceData.employeeWage).toFixed(2)}
</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-600">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-emerald-500 rounded"></div>
<span>Employee Wage ({((performanceData.employeeWage / performanceData.avgHourlyRate) * 100).toFixed(1)}%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-purple-500 rounded"></div>
<span>Vendor Fee ({performanceData.vendorFeePercentage}%)</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Performance Metrics Grid */}
<Card className="border-slate-200">
<CardHeader className="bg-gradient-to-br from-slate-50 to-white border-b border-slate-100">
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#0A39DF]" />
Key Performance Indicators
</CardTitle>
</CardHeader>
<CardContent className="p-6">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{[
{ label: "Fill Rate", value: performanceData.fillRate, icon: Target },
{ label: "On-Time Rate", value: performanceData.onTimeRate, icon: Clock },
{ label: "Billing Accuracy", value: performanceData.billingAccuracy, icon: DollarSign },
{ label: "Client Satisfaction", value: (performanceData.clientSatisfaction * 20).toFixed(0), icon: Award, suffix: "", display: `${performanceData.clientSatisfaction}/5.0` },
{ label: "Reliability Score", value: performanceData.reliability, icon: Shield },
{ label: "Compliance", value: complianceData.overall, icon: CheckCircle2 },
].map((metric, idx) => {
const Icon = metric.icon;
return (
<div key={idx} className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-3">
<Icon className="w-5 h-5 text-slate-400" />
<Badge className={getScoreBadgeColor(metric.value)}>
{metric.display || `${metric.value}${metric.suffix || '%'}`}
</Badge>
</div>
<p className="text-xs font-medium text-slate-700">{metric.label}</p>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
)}
{/* Documents Tab Content */}
{activeTab === "documents" && (
<div className="space-y-4">
{showCOI ? (
<COIViewer vendor={vendor} onClose={() => setShowCOI(false)} />
) : showW9 ? (
<W9FormViewer vendor={vendor} onClose={() => setShowW9(false)} />
) : (
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileText className="w-5 h-5 text-[#0A39DF]" />
Document Center
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{documents.map((doc, idx) => (
<div
key={idx}
className="flex items-center justify-between p-4 border border-slate-200 rounded-lg hover:border-[#0A39DF] hover:shadow-md transition-all cursor-pointer"
onClick={() => {
if (doc.type === "coi") setShowCOI(true);
else if (doc.type === "w9") setShowW9(true);
// Add handlers for other document types as needed
}}
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
<FileText className="w-6 h-6 text-[#0A39DF]" />
</div>
<div>
<p className="font-semibold text-[#1C323E] text-sm">{doc.name}</p>
<p className="text-xs text-slate-500">Last updated: {doc.lastUpdated}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge className={getStatusBadge(doc.status)}>
{doc.status}
</Badge>
<Button
variant="ghost"
size="icon"
className="text-slate-600 hover:text-[#0A39DF]"
onClick={(e) => {
// Prevent the parent div's onClick from firing when download button is clicked
e.stopPropagation();
// Implement actual download logic here
console.log(`Downloading ${doc.name}`);
}}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,133 @@
import React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Badge } from "@/components/ui/badge";
import { Award, Shield, DollarSign, TrendingUp, Users, MapPin } from "lucide-react";
export default function VendorHoverCard({ vendor, children }) {
// Mock performance data - would come from backend
const performanceData = {
overallScore: vendor.name === "Legendary Event Staffing" ? "A+" : vendor.name === "Instawork" ? "A" : "B+",
compliance: vendor.name === "Legendary Event Staffing" ? 98 : 95,
billingAccuracy: vendor.name === "Legendary Event Staffing" ? 99.5 : 96,
fillRate: vendor.name === "Legendary Event Staffing" ? 97 : 92
};
const getScoreColor = (score) => {
if (score >= 97) return "text-green-600";
if (score >= 90) return "text-blue-600";
if (score >= 80) return "text-yellow-600";
return "text-red-600";
};
return (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
{children}
</HoverCardTrigger>
<HoverCardContent className="w-96 p-0" side="right" align="start">
<div className="bg-gradient-to-br from-slate-50 to-white p-4 border-b border-slate-200">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-bold text-lg text-[#1C323E]">{vendor.name}</h3>
<p className="text-sm text-slate-600">{vendor.specialty}</p>
</div>
<Badge className={`${performanceData.overallScore === 'A+' ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'} font-bold text-lg px-3 py-1`}>
{performanceData.overallScore}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-slate-600">
<div className="flex items-center gap-1">
<MapPin className="w-4 h-4" />
<span>{vendor.region}</span>
</div>
<div className="flex items-center gap-1">
<Users className="w-4 h-4" />
<span>{vendor.employees.toLocaleString()} staff</span>
</div>
</div>
</div>
<div className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Compliance */}
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<div className="flex items-center gap-2 mb-1">
<Shield className="w-4 h-4 text-green-600" />
<span className="text-xs font-medium text-slate-600">Compliance</span>
</div>
<p className={`text-2xl font-bold ${getScoreColor(performanceData.compliance)}`}>
{performanceData.compliance}%
</p>
</div>
{/* Billing Accuracy */}
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-slate-600">Billing Accuracy</span>
</div>
<p className={`text-2xl font-bold ${getScoreColor(performanceData.billingAccuracy)}`}>
{performanceData.billingAccuracy}%
</p>
</div>
{/* Fill Rate */}
<div className="bg-emerald-50 rounded-lg p-3 border border-emerald-200 col-span-2">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-medium text-slate-600">Fill Rate</span>
</div>
<div className="flex items-center gap-3">
<p className={`text-2xl font-bold ${getScoreColor(performanceData.fillRate)}`}>
{performanceData.fillRate}%
</p>
<div className="flex-1">
<div className="w-full bg-slate-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${performanceData.fillRate}%` }}
/>
</div>
</div>
</div>
</div>
</div>
{/* Software Badge */}
<div className="pt-3 border-t border-slate-200">
<p className="text-xs text-slate-500 mb-2">Technology Stack</p>
{vendor.softwareType === "platform" && (
<Badge className="bg-green-100 text-green-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "building" && (
<Badge className="bg-blue-100 text-blue-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "partial" && (
<Badge className="bg-yellow-100 text-yellow-700">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "traditional" && (
<Badge className="bg-gray-100 text-gray-700">
{vendor.software}
</Badge>
)}
</div>
<p className="text-xs text-slate-500 text-center pt-2 border-t border-slate-200">
Click for full details
</p>
</div>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -0,0 +1,226 @@
import React from "react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Badge } from "@/components/ui/badge";
import { Award, Shield, DollarSign, TrendingUp, Users, MapPin, Building2, Phone, Mail, Briefcase, Hash } from "lucide-react";
export default function VendorScoreHoverCard({ vendor, children }) {
// Safety checks for vendor data
if (!vendor) {
return children;
}
const getScoreColor = (score) => {
if (!score) return "text-slate-400";
if (score >= 95) return "text-green-600";
if (score >= 90) return "text-blue-600";
if (score >= 85) return "text-yellow-600";
return "text-red-600";
};
const getPerformanceGrade = (fillRate) => {
if (!fillRate) return { grade: "N/A", color: "bg-slate-400" };
if (fillRate >= 97) return { grade: "A+", color: "bg-green-600" };
if (fillRate >= 95) return { grade: "A", color: "bg-green-500" };
if (fillRate >= 90) return { grade: "B+", color: "bg-blue-500" };
if (fillRate >= 85) return { grade: "B", color: "bg-yellow-500" };
return { grade: "C", color: "bg-orange-500" };
};
const performance = getPerformanceGrade(vendor.fillRate);
return (
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
{children}
</HoverCardTrigger>
<HoverCardContent className="w-[450px] p-0" side="right" align="start">
{/* Header */}
<div className="bg-gradient-to-br from-[#0A39DF] to-[#1C323E] p-5 text-white">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<div className="w-12 h-12 bg-white rounded-lg flex items-center justify-center">
<Building2 className="w-6 h-6 text-[#0A39DF]" />
</div>
<div>
<h3 className="font-bold text-lg">{vendor.name || vendor.legal_name || "Vendor"}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="bg-white/20 border-white/40 text-white font-mono text-xs">
<Hash className="w-3 h-3 mr-1" />
{vendor.vendorNumber || vendor.vendor_number || "N/A"}
</Badge>
</div>
<p className="text-sm text-white/80 mt-1">{vendor.specialty || "General Services"}</p>
</div>
</div>
<Badge className={`${performance.color} text-white font-bold text-lg px-3 py-1`}>
{performance.grade}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="bg-white/10 rounded-lg p-2">
<p className="text-xs text-white/70">Monthly Spend</p>
<p className="text-lg font-bold">{vendor.spend || "N/A"}</p>
</div>
<div className="bg-white/10 rounded-lg p-2">
<p className="text-xs text-white/70">Total Staff</p>
<p className="text-lg font-bold">{vendor.employees ? vendor.employees.toLocaleString() : "N/A"}</p>
</div>
</div>
</div>
{/* Company Information */}
<div className="p-5 space-y-4">
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-3">Company Information</h4>
<div className="space-y-2">
<div className="flex items-center gap-3">
<MapPin className="w-4 h-4 text-slate-400" />
<div>
<p className="text-sm font-medium text-slate-900">{vendor.region || "N/A"}</p>
<p className="text-xs text-slate-500">{vendor.state || "N/A"}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Briefcase className="w-4 h-4 text-slate-400" />
<div>
<p className="text-sm font-medium text-slate-900">{vendor.specialty || "N/A"}</p>
<p className="text-xs text-slate-500">Primary Service</p>
</div>
</div>
<div className="flex items-center gap-3">
<Phone className="w-4 h-4 text-slate-400" />
<p className="text-sm text-slate-700">{vendor.primary_contact_phone || "(555) 123-4567"}</p>
</div>
<div className="flex items-center gap-3">
<Mail className="w-4 h-4 text-slate-400" />
<p className="text-sm text-slate-700">{vendor.primary_contact_email || `contact@${(vendor.name || "vendor").toLowerCase().replace(/\s+/g, '').replace(/[()&]/g, '')}.com`}</p>
</div>
</div>
</div>
{/* Technology Stack */}
{vendor.software && (
<div className="pt-3 border-t border-slate-200">
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-2">Technology</h4>
{vendor.softwareType === "platform" && (
<Badge className="bg-green-100 text-green-700 font-medium">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "building" && (
<Badge className="bg-blue-100 text-blue-700 font-medium">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "partial" && (
<Badge className="bg-yellow-100 text-yellow-700 font-medium">
{vendor.software}
</Badge>
)}
{vendor.softwareType === "traditional" && (
<Badge className="bg-gray-100 text-gray-700 font-medium">
{vendor.software}
</Badge>
)}
</div>
)}
{/* Performance Metrics */}
{(vendor.fillRate || vendor.onTimeRate || vendor.csat || vendor.employees) && (
<div className="pt-3 border-t border-slate-200">
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-3">Performance Metrics</h4>
<div className="grid grid-cols-2 gap-3">
{/* Fill Rate */}
{vendor.fillRate && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-medium text-slate-600">Fill Rate</span>
</div>
<p className={`text-2xl font-bold ${getScoreColor(vendor.fillRate)}`}>
{vendor.fillRate}%
</p>
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
<div
className="bg-gradient-to-r from-emerald-500 to-emerald-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${vendor.fillRate}%` }}
/>
</div>
</div>
)}
{/* On-Time Rate */}
{vendor.onTimeRate && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Shield className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-slate-600">On-Time</span>
</div>
<p className={`text-2xl font-bold ${getScoreColor(vendor.onTimeRate)}`}>
{vendor.onTimeRate}%
</p>
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
<div
className="bg-gradient-to-r from-blue-500 to-blue-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${vendor.onTimeRate}%` }}
/>
</div>
</div>
)}
{/* Customer Satisfaction */}
{vendor.csat && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Award className="w-4 h-4 text-amber-600" />
<span className="text-xs font-medium text-slate-600">CSAT Score</span>
</div>
<p className="text-2xl font-bold text-amber-600">
{vendor.csat}/5.0
</p>
<div className="w-full bg-slate-200 rounded-full h-1.5 mt-2">
<div
className="bg-gradient-to-r from-amber-500 to-amber-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${(vendor.csat / 5) * 100}%` }}
/>
</div>
</div>
)}
{/* Workforce */}
{vendor.employees && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-purple-600" />
<span className="text-xs font-medium text-slate-600">Workforce</span>
</div>
<p className="text-2xl font-bold text-purple-600">
{vendor.employees.toLocaleString()}
</p>
<p className="text-xs text-slate-500 mt-1">Active staff</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="bg-blue-50 p-3 border-t border-blue-200">
<p className="text-xs text-center text-slate-600">
Hover over vendor name in any table to see details Click for full profile
</p>
</div>
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -0,0 +1,346 @@
import React, { useState } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { FileText, Download, Upload, Save, X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function W9FormViewer({ vendor, onClose }) {
const [formData, setFormData] = useState({
entity_name: vendor?.business_name || "",
business_name: "",
tax_classification: "",
llc_classification: "",
has_foreign_partners: false,
exempt_payee_code: "",
fatca_code: "",
address: "",
city_state_zip: "",
account_numbers: "",
ssn_part1: "",
ssn_part2: "",
ssn_part3: "",
ein_part1: "",
ein_part2: "",
tin_type: "ssn",
signature: "",
date: ""
});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = () => {
// Save logic here
console.log("Saving W9 form:", formData);
onClose?.();
};
return (
<div className="space-y-6">
{/* Header with actions */}
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
<div>
<div className="flex items-center gap-2 mb-1">
<FileText className="w-5 h-5 text-[#0A39DF]" />
<h3 className="font-bold text-lg text-[#1C323E]">Form W-9 (Rev. March 2024)</h3>
</div>
<p className="text-xs text-slate-500">Request for Taxpayer Identification Number and Certification</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Upload className="w-4 h-4 mr-2" />
Upload Signed
</Button>
<Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download PDF
</Button>
</div>
</div>
{/* Before you begin notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm font-semibold text-blue-900 mb-1">Before you begin</p>
<p className="text-xs text-blue-700">Give form to the requester. Do not send to the IRS.</p>
</div>
{/* Line 1 & 2 - Names */}
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">1. Name of entity/individual *</Label>
<p className="text-xs text-slate-500 mb-2">For a sole proprietor or disregarded entity, enter the owner's name on line 1</p>
<Input
value={formData.entity_name}
onChange={(e) => handleChange('entity_name', e.target.value)}
placeholder="Enter name as shown on your tax return"
className="font-medium"
/>
</div>
<div>
<Label className="text-sm font-semibold">2. Business name/disregarded entity name, if different from above</Label>
<Input
value={formData.business_name}
onChange={(e) => handleChange('business_name', e.target.value)}
placeholder="Enter business or DBA name (if applicable)"
/>
</div>
</div>
{/* Line 3a - Tax Classification */}
<div className="border border-slate-200 rounded-lg p-4 bg-slate-50">
<Label className="text-sm font-semibold mb-3 block">3a. Federal tax classification (Check only ONE box)</Label>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "individual"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'individual')}
/>
<Label className="text-sm font-normal cursor-pointer">Individual/sole proprietor</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "c_corp"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'c_corp')}
/>
<Label className="text-sm font-normal cursor-pointer">C corporation</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "s_corp"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 's_corp')}
/>
<Label className="text-sm font-normal cursor-pointer">S corporation</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "partnership"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'partnership')}
/>
<Label className="text-sm font-normal cursor-pointer">Partnership</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "trust"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'trust')}
/>
<Label className="text-sm font-normal cursor-pointer">Trust/estate</Label>
</div>
<div className="flex items-center space-x-2 col-span-2">
<Checkbox
checked={formData.tax_classification === "llc"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'llc')}
/>
<Label className="text-sm font-normal cursor-pointer">LLC</Label>
<span className="text-xs text-slate-500 mx-2">Enter tax classification:</span>
<Input
value={formData.llc_classification}
onChange={(e) => handleChange('llc_classification', e.target.value)}
placeholder="C, S, or P"
className="w-20 h-8"
maxLength={1}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
checked={formData.tax_classification === "other"}
onCheckedChange={(checked) => checked && handleChange('tax_classification', 'other')}
/>
<Label className="text-sm font-normal cursor-pointer">Other (see instructions)</Label>
</div>
</div>
{/* Line 3b */}
<div className="mt-4 pt-4 border-t border-slate-200">
<div className="flex items-start space-x-2">
<Checkbox
checked={formData.has_foreign_partners}
onCheckedChange={(checked) => handleChange('has_foreign_partners', checked)}
/>
<div>
<Label className="text-sm font-semibold cursor-pointer">3b. Foreign partners, owners, or beneficiaries</Label>
<p className="text-xs text-slate-500 mt-1">Check if you have any foreign partners, owners, or beneficiaries</p>
</div>
</div>
</div>
</div>
{/* Line 4 - Exemptions */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-semibold">4. Exempt payee code (if any)</Label>
<Input
value={formData.exempt_payee_code}
onChange={(e) => handleChange('exempt_payee_code', e.target.value)}
placeholder="See instructions"
className="mt-1"
/>
</div>
<div>
<Label className="text-sm font-semibold">Exemption from FATCA reporting code (if any)</Label>
<Input
value={formData.fatca_code}
onChange={(e) => handleChange('fatca_code', e.target.value)}
placeholder="Applies to accounts outside US"
className="mt-1"
/>
</div>
</div>
{/* Lines 5-7 - Address */}
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">5. Address (number, street, and apt. or suite no.)</Label>
<Input
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="Enter street address"
className="mt-1"
/>
</div>
<div>
<Label className="text-sm font-semibold">6. City, state, and ZIP code</Label>
<Input
value={formData.city_state_zip}
onChange={(e) => handleChange('city_state_zip', e.target.value)}
placeholder="City, State ZIP"
className="mt-1"
/>
</div>
<div>
<Label className="text-sm font-semibold">7. List account number(s) here (optional)</Label>
<Input
value={formData.account_numbers}
onChange={(e) => handleChange('account_numbers', e.target.value)}
placeholder="Account numbers"
className="mt-1"
/>
</div>
</div>
{/* Part I - TIN */}
<div className="border-2 border-slate-300 rounded-lg p-4 bg-white">
<h3 className="font-bold text-sm mb-4">Part I - Taxpayer Identification Number (TIN)</h3>
<p className="text-xs text-slate-600 mb-4">
Enter your TIN in the appropriate box. The TIN provided must match the name given on line 1 to avoid backup withholding.
</p>
<div className="space-y-4">
{/* SSN */}
<div>
<Label className="text-sm font-semibold mb-2 block">Social security number</Label>
<div className="flex items-center gap-2">
<Input
value={formData.ssn_part1}
onChange={(e) => handleChange('ssn_part1', e.target.value)}
maxLength={3}
placeholder="XXX"
className="w-20 text-center font-mono"
disabled={formData.tin_type === "ein"}
/>
<span className="text-lg"></span>
<Input
value={formData.ssn_part2}
onChange={(e) => handleChange('ssn_part2', e.target.value)}
maxLength={2}
placeholder="XX"
className="w-16 text-center font-mono"
disabled={formData.tin_type === "ein"}
/>
<span className="text-lg"></span>
<Input
value={formData.ssn_part3}
onChange={(e) => handleChange('ssn_part3', e.target.value)}
maxLength={4}
placeholder="XXXX"
className="w-24 text-center font-mono"
disabled={formData.tin_type === "ein"}
/>
</div>
</div>
<div className="text-center text-sm font-semibold text-slate-500">OR</div>
{/* EIN */}
<div>
<Label className="text-sm font-semibold mb-2 block">Employer identification number</Label>
<div className="flex items-center gap-2">
<Input
value={formData.ein_part1}
onChange={(e) => handleChange('ein_part1', e.target.value)}
maxLength={2}
placeholder="XX"
className="w-16 text-center font-mono"
disabled={formData.tin_type === "ssn"}
/>
<span className="text-lg"></span>
<Input
value={formData.ein_part2}
onChange={(e) => handleChange('ein_part2', e.target.value)}
maxLength={7}
placeholder="XXXXXXX"
className="w-32 text-center font-mono"
disabled={formData.tin_type === "ssn"}
/>
</div>
</div>
</div>
</div>
{/* Part II - Certification */}
<div className="border-2 border-slate-300 rounded-lg p-4 bg-yellow-50">
<h3 className="font-bold text-sm mb-3">Part II - Certification</h3>
<div className="text-xs text-slate-700 space-y-2 mb-4">
<p className="font-semibold">Under penalties of perjury, I certify that:</p>
<ol className="list-decimal list-inside space-y-1 pl-2">
<li>The number shown on this form is my correct taxpayer identification number (or I am waiting for a number to be issued to me); and</li>
<li>I am not subject to backup withholding because (a) I am exempt from backup withholding, or (b) I have not been notified by the IRS that I am subject to backup withholding as a result of a failure to report all interest or dividends, or (c) the IRS has notified me that I am no longer subject to backup withholding; and</li>
<li>I am a U.S. citizen or other U.S. person (defined below); and</li>
<li>The FATCA code(s) entered on this form (if any) indicating that I am exempt from FATCA reporting is correct.</li>
</ol>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<Label className="text-sm font-semibold">Signature of U.S. person *</Label>
<Input
value={formData.signature}
onChange={(e) => handleChange('signature', e.target.value)}
placeholder="Sign here"
className="mt-1 italic"
/>
</div>
<div>
<Label className="text-sm font-semibold">Date *</Label>
<Input
type="date"
value={formData.date}
onChange={(e) => handleChange('date', e.target.value)}
className="mt-1"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-between items-center pt-4 border-t border-slate-200">
<Badge variant="outline" className="text-xs">
Form W-9 (Rev. 3-2024)
</Badge>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button className="bg-[#0A39DF] hover:bg-[#0A39DF]/90" onClick={handleSave}>
<Save className="w-4 h-4 mr-2" />
Save W-9 Form
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,97 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props} />
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props} />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props} />
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props} />
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props} />
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props} />
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef(
({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />
)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props} />
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props} />)
);
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />)
);
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,71 @@
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}) {
return (
(<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props} />)
);
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const CarouselContext = React.createContext(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef((
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
}, [scrollPrev, scrollNext])
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
};
}, [api, onSelect])
return (
(<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}>
{children}
</div>
</CarouselContext.Provider>)
);
})
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
(<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props} />
</div>)
);
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
(<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props} />)
);
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
(<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>)
);
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
(<Button
ref={ref}
variant={variant}
size={size}
className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>)
);
})
CarouselNext.displayName = "CarouselNext"
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

309
src/components/ui/chart.jsx Normal file
View File

@@ -0,0 +1,309 @@
"use client";
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = {
light: "",
dark: ".dark"
}
const ChartContext = React.createContext(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
(<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>)
);
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({
id,
config
}) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
(<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`)
.join("\n"),
}} />)
);
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef((
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
(<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>)
);
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
(<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
(<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor
}
} />
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>)
);
})}
</div>
</div>)
);
})
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef((
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
(<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
(<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}} />
)}
{itemConfig?.label}
</div>)
);
})}
</div>)
);
})
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config,
payload,
key
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey = key
if (
key in payload &&
typeof payload[key] === "string"
) {
configLabelKey = payload[key]
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key] === "string"
) {
configLabelKey = payloadPayload[key]
}
return configLabelKey in config
? config[configLabelKey]
: config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props} />
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
...props
}) => {
return (
(<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>)
);
}
const CommandInput = React.forwardRef(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props} />
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props} />
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props} />
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props} />
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props} />
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,96 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,92 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props} />
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props} />
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props} />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props} />)
);
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

134
src/components/ui/form.jsx Normal file
View File

@@ -0,0 +1,134 @@
"use client";
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { Controller, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
const FormFieldContext = React.createContext({})
const FormField = (
{
...props
}
) => {
return (
(<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>)
);
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
const FormItemContext = React.createContext({})
const FormItem = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useId()
return (
(<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>)
);
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
(<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props} />)
);
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
(<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props} />)
);
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
(<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props} />)
);
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
(<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}>
{body}
</p>)
);
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props} />
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
(<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}>
{char}
{hasFakeCaret && (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>)
);
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
(<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,16 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({
...props
}) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({
...props
}) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({
...props
}) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({
...props
}) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className
)}
{...props} />
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props} />
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef((
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</MenubarPrimitive.Portal>
))
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props} />
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props} />
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}) => {
return (
(<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props} />)
);
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,104 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props} />
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props} />
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props} />
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}>
<div
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,100 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button";
const Pagination = ({
className,
...props
}) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props} />
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props} />
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)}
{...props} />
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (
(<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>)
);
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,42 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props} />
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}>
{withHandle && (
<div
className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

109
src/components/ui/sheet.jsx Normal file
View File

@@ -0,0 +1,109 @@
"use client";
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva } from "class-variance-authority";
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref} />
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,619 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef((
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [setOpenProp, open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
(<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>)
);
})
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef((
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
(<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}>
{children}
</div>)
);
}
if (isMobile) {
return (
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>)
);
}
return (
(<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)} />
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
{children}
</div>
</div>
</div>)
);
})
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>)
);
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
(<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />)
);
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
return (
(<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props} />)
);
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props} />)
);
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />)
);
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
return (
(<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} />)
);
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />)
);
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
return (
(<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />)
);
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
(<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props} />)
);
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} />
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef((
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
(<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>)
);
})
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
(<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props} />)
);
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, [])
return (
(<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>)
);
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef(
({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
(<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />)
);
}
)
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />)
);
}
export { Skeleton }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,29 @@
"use client";
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
(<Sonner
theme={theme}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props} />)
);
}
export { Toaster }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)} />
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,41 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props} />
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props} />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props} />
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
(<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />)
);
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,48 @@
import React from "react"
import { X } from "lucide-react"
import { motion } from "framer-motion"
export function Toast({ id, title, description, variant = "default", onClose }) {
return (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.2 } }}
className={`
pointer-events-auto relative flex w-full items-center justify-between
space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg
${variant === "destructive"
? "border-red-500 bg-red-500 text-white"
: "border-slate-200 bg-white text-slate-900"
}
`}
>
<div className="grid gap-1 flex-1">
{title && (
<div className={`text-sm font-semibold ${variant === "destructive" ? "text-white" : "text-slate-900"}`}>
{title}
</div>
)}
{description && (
<div className={`text-sm ${variant === "destructive" ? "text-white/90" : "text-slate-600"}`}>
{description}
</div>
)}
</div>
<button
onClick={onClose}
className={`
absolute right-2 top-2 rounded-md p-1 transition-all
hover:opacity-70 focus:opacity-100 focus:outline-none focus:ring-2
hover:scale-110 active:scale-95
${variant === "destructive"
? "text-white/70 hover:text-white focus:ring-red-400 hover:bg-white/10"
: "text-slate-500 hover:text-slate-900 focus:ring-slate-400 hover:bg-slate-100"
}
`}
>
<X className="h-4 w-4" />
</button>
</motion.div>
)
}

View File

@@ -0,0 +1,7 @@
import React from "react"
export function Toaster() {
// Toaster disabled - notifications now only appear in the notification panel
// All toast() calls will be silently ignored
return null;
}

View File

@@ -0,0 +1,44 @@
"use client";
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
(<ToggleGroupPrimitive.Item
ref={ref}
className={cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}), className)}
{...props}>
{children}
</ToggleGroupPrimitive.Item>)
);
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

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