Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0616f1b0 | ||
|
|
741130732f | ||
|
|
4b21a57372 | ||
|
|
b518312696 | ||
|
|
194070ea7f | ||
|
|
80265bdf6b | ||
|
|
56aeda3f1a | ||
|
|
5748885698 | ||
|
|
d3f5d494ca | ||
|
|
fbbb82cde6 | ||
|
|
9a9bcfd3cd | ||
|
|
66f562c3a1 | ||
|
|
75f98cecb3 | ||
|
|
8f596bf476 | ||
|
|
60ee5aaed4 | ||
|
|
33a11828d5 | ||
|
|
5cbf6baa3a | ||
|
|
447f0b567f | ||
|
|
cb99b4b704 | ||
|
|
8c43480fb8 | ||
|
|
9d4045881c | ||
|
|
7014fa9e13 | ||
|
|
9c32d2a3a4 | ||
|
|
1a28b05cdc | ||
|
|
b9d2e1fee6 | ||
|
|
cdf4125a24 | ||
|
|
218670d336 | ||
|
|
41d1789bcf | ||
|
|
fc1cfc447c | ||
|
|
edf801856a | ||
|
|
cdbf1edf84 | ||
|
|
2452aceaf0 | ||
|
|
924aede2ca | ||
|
|
04a6607904 |
@@ -0,0 +1,4 @@
|
||||
# Development Environment
|
||||
VITE_API_URL=https://ardev-api.maskantech.in/api
|
||||
VITE_ENV=dev
|
||||
VITE_APP_NAME=AeroResolve (Dev)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Example environment configuration
|
||||
# Copy this file to .env.local, .env.dev, .env.test, or .env.uat and update values
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
VITE_ENV=local
|
||||
VITE_APP_NAME=AeroResolve
|
||||
@@ -0,0 +1,4 @@
|
||||
# Local Environment
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
VITE_ENV=local
|
||||
VITE_APP_NAME=AeroResolve
|
||||
@@ -0,0 +1,4 @@
|
||||
# Test/QA Environment
|
||||
VITE_API_URL=https://artest-api.maskantech.in/api
|
||||
VITE_ENV=test
|
||||
VITE_APP_NAME=AeroResolve (Test)
|
||||
@@ -0,0 +1,4 @@
|
||||
# UAT Environment
|
||||
VITE_API_URL=https://api-uat.example.com/api
|
||||
VITE_ENV=uat
|
||||
VITE_APP_NAME=AeroResolve (UAT)
|
||||
@@ -10,7 +10,6 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Environment Configuration Guide
|
||||
|
||||
This project supports multiple environment configurations for local development, development server, testing, and UAT environments.
|
||||
|
||||
## Environment Files
|
||||
|
||||
- `.env.example` - Template file with all available environment variables
|
||||
- `.env.local` - Local development environment (default)
|
||||
- `.env.dev` - Development server environment
|
||||
- `.env.test` - Test/QA environment
|
||||
- `.env.uat` - User Acceptance Testing environment
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
1. **Copy the example file to create your environment file:**
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
2. **Update the API URLs and configuration in your chosen environment file** with actual server URLs.
|
||||
|
||||
3. **Commit only `.env.example` to version control** - all `.env.*` files are gitignored.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Development Servers
|
||||
|
||||
- **Dev Environment (Port 9501):**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- **Test Environment (Port 9502):**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
- **UAT Environment (Port 5176):**
|
||||
```bash
|
||||
npm run uat
|
||||
```
|
||||
|
||||
- **Local Development (Port 5174):**
|
||||
```bash
|
||||
npm run dev:local
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
- **Build for Dev:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
- **Build for Test:**
|
||||
```bash
|
||||
npm run build:test
|
||||
```
|
||||
|
||||
- **Build for UAT:**
|
||||
```bash
|
||||
npm run build:uat
|
||||
```
|
||||
|
||||
- **Build for Local:**
|
||||
```bash
|
||||
npm run build:local
|
||||
```
|
||||
|
||||
### Preview Production Build
|
||||
|
||||
- **Preview Dev Build:**
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
- **Preview Test Build:**
|
||||
```bash
|
||||
npm run preview:test
|
||||
```
|
||||
|
||||
- **Preview UAT Build:**
|
||||
```bash
|
||||
npm run preview:uat
|
||||
```
|
||||
|
||||
- **Preview Local Build:**
|
||||
```bash
|
||||
npm run preview:local
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Each environment file contains these variables:
|
||||
|
||||
- `VITE_API_URL` - Backend API endpoint URL
|
||||
- `VITE_ENV` - Environment name (local, dev, test, uat)
|
||||
- `VITE_APP_NAME` - Application name/title
|
||||
|
||||
You can add more variables as needed. They should be prefixed with `VITE_` to be accessible in the browser.
|
||||
|
||||
## Accessing Environment Variables in Code
|
||||
|
||||
Access environment variables in your React components:
|
||||
|
||||
```typescript
|
||||
const apiUrl = import.meta.env.VITE_API_URL
|
||||
const environment = import.meta.env.VITE_ENV
|
||||
const appName = import.meta.env.VITE_APP_NAME
|
||||
```
|
||||
|
||||
Example in ApiClient.ts:
|
||||
```typescript
|
||||
const baseURL = import.meta.env.VITE_API_URL
|
||||
```
|
||||
|
||||
## Development Ports
|
||||
|
||||
Each environment runs on a different port:
|
||||
|
||||
| Environment | Port | Command |
|
||||
|------------|-------|---------------|
|
||||
| Dev | 9501 | `npm run dev` |
|
||||
| Test | 9502 | `npm run test`|
|
||||
| UAT | 5176 | `npm run uat` |
|
||||
| Local | 5174 | `npm run dev:local` |
|
||||
|
||||
## Notes
|
||||
|
||||
- All environment files except `.env.example` are gitignored
|
||||
- Never commit sensitive API keys or credentials to version control
|
||||
- Use `.env.example` as a template for new environment setup
|
||||
- Vite automatically loads the correct `.env.*` file based on the mode flag
|
||||
@@ -17,14 +17,53 @@ This is the frontend application built with React and Vite.
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
Start the local development server:
|
||||
### Running the Application
|
||||
|
||||
Start the local development server for your desired environment:
|
||||
```bash
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Local
|
||||
npm run local
|
||||
|
||||
# Test
|
||||
npm run test
|
||||
|
||||
# UAT
|
||||
npm run uat
|
||||
```
|
||||
|
||||
### Build for Production
|
||||
To build the application for production:
|
||||
|
||||
To build the application for a specific environment:
|
||||
```bash
|
||||
npm run build
|
||||
# Development
|
||||
npm run build:dev
|
||||
|
||||
# Local
|
||||
npm run build:local
|
||||
|
||||
# Test
|
||||
npm run build:test
|
||||
|
||||
# UAT
|
||||
npm run build:uat
|
||||
```
|
||||
|
||||
### Preview Built Application
|
||||
|
||||
To preview the production build locally:
|
||||
```bash
|
||||
# Development
|
||||
npm run preview:dev
|
||||
|
||||
# Local
|
||||
npm run preview:local
|
||||
|
||||
# Test
|
||||
npm run preview:test
|
||||
|
||||
# UAT
|
||||
npm run preview:uat
|
||||
```
|
||||
|
||||
+12
-3
@@ -4,10 +4,19 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite --mode dev",
|
||||
"local": "vite --mode localhost",
|
||||
"test": "vite --mode test",
|
||||
"uat": "vite --mode uat",
|
||||
"build:dev": "tsc -b && vite build --mode dev",
|
||||
"build:local": "tsc -b && vite build --mode localhost",
|
||||
"build:test": "tsc -b && vite build --mode test",
|
||||
"build:uat": "tsc -b && vite build --mode uat",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview:dev": "vite preview --mode dev",
|
||||
"preview:local": "vite preview --mode localhost",
|
||||
"preview:test": "vite preview --mode test",
|
||||
"preview:uat": "vite preview --mode uat"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Route, Routes } from 'react-router-dom'
|
||||
import Layout from './layout/AppLayout'
|
||||
import HomePage from './app/dashboard'
|
||||
import CohortManage from './app/cohartManage'
|
||||
import PolicyEngineList from './app/policyEngine/components/PolicyEngineList'
|
||||
import AddPolicyEngine from './app/policyEngine/components/AddPolicyEngine'
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
@@ -9,6 +11,8 @@ function AppRoutes() {
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/cohorts" element={<CohortManage />} />
|
||||
<Route path="/policy-engine" element={<PolicyEngineList />} />
|
||||
<Route path="/policy-engine/add" element={<AddPolicyEngine />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type {
|
||||
CohartResponse,
|
||||
CreateCohartPayload,
|
||||
UpdateCohartPayload
|
||||
import type {
|
||||
CohartResponse,
|
||||
PaginatedCohartResponse,
|
||||
CreateCohartPayload,
|
||||
UpdateCohartPayload,
|
||||
UpdateCohartStatusPayload,
|
||||
} from './CohartManageTypes';
|
||||
|
||||
/**
|
||||
* List all coharts
|
||||
*/
|
||||
export function listCoharts(): Promise<CohartResponse[]> {
|
||||
return ApiClient.get<any, CohartResponse[]>('/coharts');
|
||||
// ─── Cohort CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export function listCoharts(page = 1, limit = 10): Promise<PaginatedCohartResponse> {
|
||||
return ApiClient.get<any, PaginatedCohartResponse>(`/cohorts?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific cohart by ID
|
||||
*/
|
||||
export function getCohart(id: string): Promise<CohartResponse> {
|
||||
return ApiClient.get<any, CohartResponse>(`/coharts/${id}`);
|
||||
return ApiClient.get<any, CohartResponse>(`/cohorts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cohart
|
||||
*/
|
||||
export function createCohart(payload: CreateCohartPayload): Promise<CohartResponse> {
|
||||
return ApiClient.post<any, CohartResponse>('/coharts', payload);
|
||||
return ApiClient.post<any, CohartResponse>('/cohorts', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing cohart
|
||||
*/
|
||||
export function updateCohart(id: string, payload: UpdateCohartPayload): Promise<CohartResponse> {
|
||||
return ApiClient.patch<any, CohartResponse>(`/coharts/${id}`, payload);
|
||||
return ApiClient.put<any, CohartResponse>(`/cohorts/${id}`, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cohart
|
||||
*/
|
||||
export function deleteCohart(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/coharts/${id}`);
|
||||
export function updateCohartStatus(id: string, payload: UpdateCohartStatusPayload): Promise<CohartResponse> {
|
||||
return ApiClient.patch<any, CohartResponse>(`/cohorts/${id}/status`, payload);
|
||||
}
|
||||
|
||||
export function deleteCohart(id: string): Promise<void> {
|
||||
return ApiClient.delete<any, void>(`/cohorts/${id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,77 @@
|
||||
export interface CohartMember {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
// ─── Core Cohort Response (matches backend tbl_cohorts) ────────────────────
|
||||
|
||||
export type CohortStatus = 'Draft' | 'Active' | 'Inactive';
|
||||
export type HighValuePassenger = 'Any' | 'Yes(VIP/Strategic)' | 'No';
|
||||
export type FlightType = 'Domestic Only' | 'International Only' | 'Both (All)';
|
||||
|
||||
export interface CohartResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: string;
|
||||
description: string;
|
||||
membersCount: number;
|
||||
status: CohortStatus;
|
||||
// Passenger Attributes
|
||||
cabinClasses: MasterDataOption[];
|
||||
passengerTypes: MasterDataOption[];
|
||||
ancillaryPurchases: MasterDataOption[];
|
||||
// Customer Value
|
||||
loyaltyTiers: MasterDataOption[];
|
||||
revenueSegments: MasterDataOption[];
|
||||
highValuePassenger: HighValuePassenger;
|
||||
// Journey Context
|
||||
flightType: FlightType | null;
|
||||
regions: MasterDataOption[];
|
||||
tripPurposes: MasterDataOption[];
|
||||
originAirport: string[];
|
||||
destinationAirport: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Paginated List Response ────────────────────────────────────────────────
|
||||
|
||||
export interface PaginatedCohartResponse {
|
||||
data: CohartResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// ─── Create / Update Payloads ───────────────────────────────────────────────
|
||||
|
||||
export interface CreateCohartPayload {
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
description?: string;
|
||||
status: CohortStatus;
|
||||
// Passenger Attributes
|
||||
cabinClassIds?: string[];
|
||||
passengerTypeIds?: string[];
|
||||
ancillaryPurchaseIds?: string[];
|
||||
// Customer Value
|
||||
loyaltyTierIds?: string[];
|
||||
revenueSegmentIds?: string[];
|
||||
highValuePassenger?: HighValuePassenger;
|
||||
// Journey Context
|
||||
flightType?: FlightType;
|
||||
regionIds?: string[];
|
||||
tripPurposeIds?: string[];
|
||||
originAirports?: string[];
|
||||
destinationAirports?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateCohartPayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
export interface UpdateCohartPayload extends Partial<CreateCohartPayload> {}
|
||||
|
||||
export interface UpdateCohartStatusPayload {
|
||||
status: CohortStatus;
|
||||
}
|
||||
|
||||
// ─── Master Data Option (for dropdowns loaded from API) ─────────────────────
|
||||
|
||||
export interface MasterDataOption {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@@ -1,267 +1,664 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, FileText, BarChart2, CheckCircle, ShieldCheck, ChevronRight } from 'lucide-react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { FileText, ChevronRight, ChevronLeft, User, CircleCheck, Globe } from 'lucide-react';
|
||||
import {
|
||||
CustomModal,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomMultiSelect,
|
||||
CustomTextArea,
|
||||
CustomButton,
|
||||
CustomStatus,
|
||||
CustomRadio,
|
||||
CustomCheckBox,
|
||||
CustomAccordionSection,
|
||||
} from '../../../components/custom';
|
||||
import CustomSuccessModal from '../../../components/custom/CustomSuccessModal';
|
||||
import { createCohart, updateCohart } from '../CohartManageApi';
|
||||
import {
|
||||
getCabinClasses,
|
||||
getPassengerTypes,
|
||||
getAncillaryPurchases,
|
||||
getMembershipTiers,
|
||||
getRevenueSegments,
|
||||
getRegions,
|
||||
getTripPurposes,
|
||||
} from '../../masterData/MasterDataApi';
|
||||
import type { MasterDataItem } from '../../masterData/MasterDataTypes';
|
||||
import type {
|
||||
CohartResponse,
|
||||
CreateCohartPayload,
|
||||
CohortStatus,
|
||||
FlightType,
|
||||
HighValuePassenger,
|
||||
} from '../CohartManageTypes';
|
||||
|
||||
// ─── Static Options ──────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'Draft', value: 'Draft' },
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
];
|
||||
|
||||
const HIGH_VALUE_OPTIONS: { label: string; value: HighValuePassenger }[] = [
|
||||
{ label: 'Any', value: 'Any' },
|
||||
{ label: 'Yes (VIP/Strategic)', value: 'Yes(VIP/Strategic)' },
|
||||
{ label: 'No', value: 'No' },
|
||||
];
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ label: 'Route (One Origin to Many Destinations)', value: 'Route (One Origin to Many Destinations)' },
|
||||
{ label: 'Route (Many Destinations to One Origin)', value: 'Route (Many Destinations to One Origin)' },
|
||||
{ label: 'Source (Any Destination)', value: 'Source (Any Destination)' },
|
||||
{ label: 'Destination (Any Source)', value: 'Destination (Any Source)' }
|
||||
];
|
||||
|
||||
const AIRPORTS = [
|
||||
{ label: 'DOH (Doha)', value: 'DOH' },
|
||||
{ label: 'LHR (London Heathrow)', value: 'LHR' },
|
||||
{ label: 'JFK (New York)', value: 'JFK' },
|
||||
{ label: 'CDG (Paris)', value: 'CDG' },
|
||||
{ label: 'DXB (Dubai)', value: 'DXB' },
|
||||
{ label: 'SIN (Singapore)', value: 'SIN' },
|
||||
];
|
||||
|
||||
const SECTION_ORDER = ['passenger', 'customer', 'journey'] as const;
|
||||
type Section = typeof SECTION_ORDER[number];
|
||||
|
||||
const STEPS = [
|
||||
{ num: 1, label: 'INFORMATION', subtitle: 'Cohort Identity' },
|
||||
{ num: 2, label: 'TARGETING', subtitle: 'Targeting Criteria' },
|
||||
];
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StepOneData {
|
||||
name: string;
|
||||
status: CohortStatus | '';
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface StepTwoData {
|
||||
cabinClassIds: string[];
|
||||
passengerTypeIds: string[];
|
||||
ancillaryPurchaseIds: string[];
|
||||
loyaltyTierIds: string[];
|
||||
revenueSegmentIds: string[];
|
||||
highValuePassenger: HighValuePassenger;
|
||||
flightType: string;
|
||||
regionIds: string[];
|
||||
tripPurposeIds: string[];
|
||||
scopeType: string;
|
||||
originAirports: string[];
|
||||
destinationAirports: string[];
|
||||
}
|
||||
|
||||
interface AddCohartProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (cohort: any) => void;
|
||||
onSuccess: () => void;
|
||||
editData?: CohartResponse;
|
||||
}
|
||||
|
||||
export default function AddCohart({ isOpen, onClose, onAdd }: AddCohartProps) {
|
||||
// ─── Master Data Dropdown wrapper ─────────────────────────────────────────────
|
||||
|
||||
function toDropdownOptions(items: MasterDataItem[]) {
|
||||
return items
|
||||
.filter(m => m.isActive)
|
||||
.map(m => ({ label: m.label, value: m.id }));
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
const EMPTY_STEP_ONE: StepOneData = { name: '', status: '', description: '' };
|
||||
const EMPTY_STEP_TWO: StepTwoData = {
|
||||
cabinClassIds: [],
|
||||
passengerTypeIds: [],
|
||||
ancillaryPurchaseIds: [],
|
||||
loyaltyTierIds: [],
|
||||
revenueSegmentIds: [],
|
||||
highValuePassenger: 'Any',
|
||||
flightType: '',
|
||||
regionIds: [],
|
||||
tripPurposeIds: [],
|
||||
scopeType: '',
|
||||
originAirports: [],
|
||||
destinationAirports: [],
|
||||
};
|
||||
|
||||
export default function AddCohart({ isOpen, onClose, onSuccess, editData }: AddCohartProps) {
|
||||
const isEditMode = !!editData;
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
businessOwner: '',
|
||||
status: 'Active',
|
||||
description: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openSection, setOpenSection] = useState('passenger');
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
// Step 1
|
||||
const [stepOne, setStepOne] = useState<StepOneData>(EMPTY_STEP_ONE);
|
||||
// Step 2
|
||||
const [stepTwo, setStepTwo] = useState<StepTwoData>(EMPTY_STEP_TWO);
|
||||
|
||||
const handleNext = () => setStep((s) => Math.min(4, s + 1));
|
||||
const handlePrev = () => setStep((s) => Math.max(1, s - 1));
|
||||
// Master data options from API
|
||||
const [cabinClasses, setCabinClasses] = useState<MasterDataItem[]>([]);
|
||||
const [passengerTypes, setPassengerTypes] = useState<MasterDataItem[]>([]);
|
||||
const [ancillaryPurchases, setAncillaryPurchases] = useState<MasterDataItem[]>([]);
|
||||
const [membershipTiers, setMembershipTiers] = useState<MasterDataItem[]>([]);
|
||||
const [revenueSegments, setRevenueSegments] = useState<MasterDataItem[]>([]);
|
||||
const [regions, setRegions] = useState<MasterDataItem[]>([]);
|
||||
const [tripPurposes, setTripPurposes] = useState<MasterDataItem[]>([]);
|
||||
const [masterDataLoading, setMasterDataLoading] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onAdd({
|
||||
id: Date.now(),
|
||||
name: formData.name || 'New Cohort',
|
||||
description: formData.description || 'No description',
|
||||
status: formData.status,
|
||||
lastModified: new Date().toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).replace(',', ''),
|
||||
modifiedBy: formData.businessOwner || 'Current User',
|
||||
});
|
||||
// Load master data when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch master data on modal open
|
||||
setMasterDataLoading(true);
|
||||
Promise.all([
|
||||
getCabinClasses(),
|
||||
getPassengerTypes(),
|
||||
getAncillaryPurchases(),
|
||||
getMembershipTiers(),
|
||||
getRevenueSegments(),
|
||||
getRegions(),
|
||||
getTripPurposes(),
|
||||
])
|
||||
.then(([cc, pt, ap, mt, rs, rg, tp]) => {
|
||||
setCabinClasses(cc);
|
||||
setPassengerTypes(pt);
|
||||
setAncillaryPurchases(ap);
|
||||
setMembershipTiers(mt);
|
||||
setRevenueSegments(rs);
|
||||
setRegions(rg);
|
||||
setTripPurposes(tp);
|
||||
})
|
||||
.catch(() => setError('Failed to load dropdown options. Please try again.'))
|
||||
.finally(() => setMasterDataLoading(false));
|
||||
}, [isOpen]);
|
||||
|
||||
// Pre-fill form when editing
|
||||
useEffect(() => {
|
||||
if (isOpen && editData) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync form fields from editData when modal opens in edit mode
|
||||
setStepOne({
|
||||
name: editData.name,
|
||||
status: editData.status,
|
||||
description: editData.description || '',
|
||||
});
|
||||
setStepTwo({
|
||||
cabinClassIds: editData.cabinClasses?.map(c => c.id) || [],
|
||||
passengerTypeIds: editData.passengerTypes?.map(c => c.id) || [],
|
||||
ancillaryPurchaseIds: editData.ancillaryPurchases?.map(c => c.id) || [],
|
||||
loyaltyTierIds: editData.loyaltyTiers?.map(c => c.id) || [],
|
||||
revenueSegmentIds: editData.revenueSegments?.map(c => c.id) || [],
|
||||
highValuePassenger: editData.highValuePassenger || 'Any',
|
||||
flightType: editData.flightType || '',
|
||||
regionIds: editData.regions?.map(c => c.id) || [],
|
||||
tripPurposeIds: editData.tripPurposes?.map(c => c.id) || [],
|
||||
scopeType: editData.scopeType || '',
|
||||
originAirports: editData.originAirport || [],
|
||||
destinationAirports: editData.destinationAirport || [],
|
||||
});
|
||||
} else if (isOpen && !editData) {
|
||||
setStepOne(EMPTY_STEP_ONE);
|
||||
setStepTwo(EMPTY_STEP_TWO);
|
||||
}
|
||||
}, [isOpen, editData]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setFormData({ name: '', businessOwner: '', status: 'Active', description: '' });
|
||||
setError(null);
|
||||
setOpenSection('passenger');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between bg-white">
|
||||
<div className="flex gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center text-[#1B9869]">
|
||||
<FileText size={24} />
|
||||
const canAdvance = stepOne.name.trim() !== '' && stepOne.status !== '';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload: CreateCohartPayload = {
|
||||
name: stepOne.name.trim(),
|
||||
description: stepOne.description.trim() || undefined,
|
||||
status: stepOne.status as CohortStatus,
|
||||
cabinClassIds: stepTwo.cabinClassIds.length ? stepTwo.cabinClassIds : undefined,
|
||||
passengerTypeIds: stepTwo.passengerTypeIds.length ? stepTwo.passengerTypeIds : undefined,
|
||||
ancillaryPurchaseIds: stepTwo.ancillaryPurchaseIds.length ? stepTwo.ancillaryPurchaseIds : undefined,
|
||||
loyaltyTierIds: stepTwo.loyaltyTierIds.length ? stepTwo.loyaltyTierIds : undefined,
|
||||
revenueSegmentIds: stepTwo.revenueSegmentIds.length ? stepTwo.revenueSegmentIds : undefined,
|
||||
highValuePassenger: stepTwo.highValuePassenger || undefined,
|
||||
flightType: (stepTwo.flightType as FlightType) || undefined,
|
||||
regionIds: stepTwo.regionIds.length ? stepTwo.regionIds : undefined,
|
||||
tripPurposeIds: stepTwo.tripPurposeIds.length ? stepTwo.tripPurposeIds : undefined,
|
||||
scopeType: stepTwo.scopeType || undefined,
|
||||
originAirports: stepTwo.originAirports.length ? stepTwo.originAirports : undefined,
|
||||
destinationAirports: stepTwo.destinationAirports.length ? stepTwo.destinationAirports : undefined,
|
||||
};
|
||||
|
||||
if (isEditMode && editData) {
|
||||
await updateCohart(editData.id, payload);
|
||||
} else {
|
||||
await createCohart(payload);
|
||||
}
|
||||
|
||||
setShowSuccessModal(true);
|
||||
} catch {
|
||||
setError(`Failed to ${isEditMode ? 'update' : 'create'} cohort. Please try again.`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setOpenSection(prev => (prev === section ? '' : section));
|
||||
};
|
||||
|
||||
const passengerHasData =
|
||||
stepTwo.cabinClassIds.length > 0 || stepTwo.passengerTypeIds.length > 0 || stepTwo.ancillaryPurchaseIds.length > 0;
|
||||
|
||||
const customerHasData = stepTwo.loyaltyTierIds.length > 0 || stepTwo.revenueSegmentIds.length > 0;
|
||||
|
||||
const journeyHasData =
|
||||
stepTwo.flightType !== '' ||
|
||||
stepTwo.regionIds.length > 0 ||
|
||||
stepTwo.tripPurposeIds.length > 0 ||
|
||||
stepTwo.scopeType !== '' ||
|
||||
stepTwo.originAirports.length > 0 ||
|
||||
stepTwo.destinationAirports.length > 0;
|
||||
|
||||
// Auto-advance to the next section the moment the active one gains its
|
||||
// first value. Reopening a prior section later never re-triggers this,
|
||||
// since it only fires on the false -> true transition while that section
|
||||
// is the one currently open.
|
||||
const sectionHasData: Partial<Record<Section, boolean>> = useMemo(
|
||||
() => ({ passenger: passengerHasData, customer: customerHasData }),
|
||||
[passengerHasData, customerHasData]
|
||||
);
|
||||
const prevSectionHasData = useRef(sectionHasData);
|
||||
|
||||
useEffect(() => {
|
||||
const current = openSection as Section;
|
||||
const currentIndex = SECTION_ORDER.indexOf(current);
|
||||
const justFilled = !prevSectionHasData.current[current] && sectionHasData[current];
|
||||
if (justFilled && currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
|
||||
setOpenSection(SECTION_ORDER[currentIndex + 1]);
|
||||
}
|
||||
prevSectionHasData.current = sectionHasData;
|
||||
}, [sectionHasData, openSection]);
|
||||
|
||||
const advanceSection = () => {
|
||||
const currentIndex = SECTION_ORDER.indexOf(openSection as Section);
|
||||
if (currentIndex >= 0 && currentIndex < SECTION_ORDER.length - 1) {
|
||||
setOpenSection(SECTION_ORDER[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const isOnFinalSection = openSection === 'journey';
|
||||
|
||||
// ─── Stepper ───────────────────────────────────────────────────────────────
|
||||
|
||||
const stepper = (
|
||||
<div className="flex items-center gap-[18px]">
|
||||
{STEPS.map((s, i) => {
|
||||
const reached = step >= s.num;
|
||||
const isCurrent = step === s.num;
|
||||
const eyebrowColor = reached ? 'text-[#059669]' : 'text-slate-400';
|
||||
const titleColor = !reached
|
||||
? 'text-slate-400'
|
||||
: isCurrent && i > 0
|
||||
? 'text-[#0F172A]'
|
||||
: 'text-[#059669]';
|
||||
return (
|
||||
<React.Fragment key={s.num}>
|
||||
<div className={`flex flex-col gap-1 min-w-[140px] ${i === 1 ? 'items-end text-right' : ''}`}>
|
||||
<p className={`text-[10px] font-semibold leading-none tracking-[1px] uppercase ${eyebrowColor}`}>
|
||||
{s.num} – {s.label}
|
||||
</p>
|
||||
<p className={`text-[18px] font-semibold leading-none ${titleColor}`}>
|
||||
{s.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">New Dynamic Cohorts</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure targeting criteria for high-precision recovery.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="px-8 py-6 bg-white border-b border-gray-100 flex items-center justify-between">
|
||||
{[
|
||||
{ num: 1, title: 'INFORMATION', subtitle: 'Cohort Identity' },
|
||||
{ num: 2, title: 'TARGETING', subtitle: 'Targeting Criteria' },
|
||||
{ num: 3, title: 'PREVIEW', subtitle: 'Audience Insights' },
|
||||
{ num: 4, title: 'VALIDATE', subtitle: 'Final Validation' },
|
||||
].map((s, i) => (
|
||||
<React.Fragment key={s.num}>
|
||||
<div className="flex flex-col min-w-[140px]">
|
||||
<p className={`text-[10px] font-bold tracking-wider mb-1 ${step >= s.num ? 'text-[#1B9869]' : 'text-gray-400'}`}>
|
||||
{s.num} - {s.title}
|
||||
</p>
|
||||
<p className={`text-base font-bold ${step >= s.num ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{s.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{i !== 3 && (
|
||||
<div className={`flex-1 h-px mx-4 ${step > s.num ? 'bg-[#1B9869]' : 'bg-gray-200'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8 overflow-y-auto flex-1 bg-white">
|
||||
{step === 1 && (
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Cohort Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter"
|
||||
className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Business Owner</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter"
|
||||
className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all"
|
||||
value={formData.businessOwner}
|
||||
onChange={(e) => setFormData({ ...formData, businessOwner: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all appearance-none"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
</select>
|
||||
<ChevronRight size={16} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 rotate-90 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
placeholder="Enter"
|
||||
rows={4}
|
||||
className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all resize-none"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Cabin Class</label>
|
||||
<div className="relative">
|
||||
<select className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all appearance-none">
|
||||
<option>Business Class</option>
|
||||
<option>Economy</option>
|
||||
<option>First Class</option>
|
||||
</select>
|
||||
<ChevronRight size={16} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 rotate-90 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Passenger Type</label>
|
||||
<div className="relative">
|
||||
<select className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all appearance-none">
|
||||
<option>Adult</option>
|
||||
<option>Child</option>
|
||||
<option>Infant</option>
|
||||
</select>
|
||||
<ChevronRight size={16} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 rotate-90 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Ancillary Purchase</label>
|
||||
<div className="relative">
|
||||
<select className="w-full px-4 py-3 bg-white border border-gray-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#1B9869]/20 focus:border-[#1B9869] transition-all appearance-none">
|
||||
<option>All Ancillary Services</option>
|
||||
<option>Extra Baggage</option>
|
||||
<option>Seat Selection</option>
|
||||
</select>
|
||||
<ChevronRight size={16} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 rotate-90 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-xl flex justify-between items-center bg-gray-50/50 cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<span className="text-sm font-bold text-gray-700 uppercase">Customer Value</span>
|
||||
<ChevronRight size={18} className="text-gray-400 rotate-90" />
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-xl flex justify-between items-center bg-gray-50/50 cursor-pointer hover:bg-gray-50 transition-colors">
|
||||
<span className="text-sm font-bold text-gray-700 uppercase">Journey Context</span>
|
||||
<ChevronRight size={18} className="text-gray-400 rotate-90" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#1B9869]/10 flex items-center justify-center text-[#1B9869]">
|
||||
<BarChart2 size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 uppercase">Audience Insights</h3>
|
||||
<p className="text-sm text-gray-500">Prediction based on historical data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total Matching', value: '4,281' },
|
||||
{ label: 'Active Recovery', value: '182', color: 'text-[#1B9869]' },
|
||||
{ label: 'Avg. Refund', value: '$245', color: 'text-yellow-500' },
|
||||
{ label: 'Est. Value', value: '$1.1M' },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="p-5 border border-gray-100 bg-gray-50/50 rounded-2xl flex flex-col gap-2 shadow-sm">
|
||||
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-wider">{stat.label}</span>
|
||||
<span className={`text-3xl font-black ${stat.color || 'text-gray-900'}`}>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-6 bg-[#1B9869]/5 border border-[#1B9869]/20 rounded-2xl shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[#1B9869] font-bold text-sm tracking-wide mb-3 uppercase">
|
||||
<ShieldCheck size={16} />
|
||||
Predictive Intelligence
|
||||
</div>
|
||||
<p className="text-gray-700 italic text-sm font-medium">
|
||||
"This audience represents 12% of your high-value segment. Members are 4x more likely to escalate disruption."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#1B9869]/10 flex items-center justify-center text-[#1B9869]">
|
||||
<CheckCircle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 uppercase">Final Validation</h3>
|
||||
<p className="text-sm text-gray-500">Confirm segment configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 bg-gray-50/50">
|
||||
<span className="text-sm font-semibold text-gray-700">Logic Consistency</span>
|
||||
<span className="text-xs font-bold text-[#1B9869] bg-[#1B9869]/10 px-3 py-1 rounded-full border border-[#1B9869]/20">VALIDATED</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-gray-50/50">
|
||||
<span className="text-sm font-semibold text-gray-700">Policy Conflict Check</span>
|
||||
<span className="text-xs font-bold text-[#1B9869] bg-[#1B9869]/10 px-3 py-1 rounded-full border border-[#1B9869]/20">NO CONFLICTS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-100 bg-white flex justify-between items-center">
|
||||
{step === 1 ? (
|
||||
<button onClick={onClose} className="px-6 py-2.5 border border-[#1B9869] text-[#1B9869] rounded-xl text-sm font-bold hover:bg-[#1B9869]/5 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handlePrev} className="px-6 py-2.5 border border-gray-200 text-gray-700 rounded-xl text-sm font-bold hover:bg-gray-50 transition-colors">
|
||||
Previous Step
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={step === 4 ? handleSubmit : handleNext}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-[#1B9869] hover:bg-[#157a54] text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-[#1B9869]/20"
|
||||
>
|
||||
{step === 4 ? 'Create Cohort' : step === 1 ? 'Next Step' : `Continue to Step 0${step + 1}`}
|
||||
{step !== 4 && <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div
|
||||
className="flex-1 h-[2px] rounded-full"
|
||||
style={
|
||||
step > 1
|
||||
? { background: 'linear-gradient(90deg, #EBF6F3 0%, #A9DACB 71.31%)' }
|
||||
: { background: 'linear-gradient(90deg, #EBF6F3 0%, #CBD5E1 71.31%)' }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Footer ────────────────────────────────────────────────────────────────
|
||||
|
||||
const footer = (
|
||||
<div className="flex justify-between w-full">
|
||||
{step === 1 ? (
|
||||
<CustomButton variant="outlined" onClick={handleClose}>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
) : (
|
||||
<CustomButton variant="outlined" leftIcon={<ChevronLeft size={18} />} onClick={() => setStep(1)}>
|
||||
Previous Step
|
||||
</CustomButton>
|
||||
)}
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
rightIcon={step === 2 && isOnFinalSection ? undefined : <ChevronRight size={18} />}
|
||||
onClick={
|
||||
step === 1
|
||||
? () => setStep(2)
|
||||
: isOnFinalSection
|
||||
? handleSubmit
|
||||
: advanceSection
|
||||
}
|
||||
disabled={(step === 1 && !canAdvance) || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{step === 1 ? 'Next Step' : isOnFinalSection ? (isEditMode ? 'Update' : 'Submit') : 'Next'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomModal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
size="lg"
|
||||
className="!max-w-[750px]"
|
||||
title={isEditMode ? 'Edit Cohort' : 'New Dynamic Cohorts'}
|
||||
description="Configure targeting criteria for high-precision recovery."
|
||||
icon={<FileText size={20} />}
|
||||
headerExtra={stepper}
|
||||
footer={footer}
|
||||
allowBackdropClose={false}
|
||||
contentClassName="!p-0"
|
||||
>
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{/* ── Step 1: Cohort Identity ── */}
|
||||
{step === 1 && (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-5">
|
||||
<CustomInput
|
||||
label="Cohort Name"
|
||||
required
|
||||
placeholder="Enter"
|
||||
value={stepOne.name}
|
||||
onChange={e => setStepOne({ ...stepOne, name: e.target.value })}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
required
|
||||
options={STATUS_OPTIONS}
|
||||
value={stepOne.status}
|
||||
onChange={val => setStepOne({ ...stepOne, status: val as CohortStatus })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<CustomTextArea
|
||||
label="Description"
|
||||
placeholder="Enter"
|
||||
rows={4}
|
||||
value={stepOne.description}
|
||||
onChange={e => setStepOne({ ...stepOne, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Targeting Criteria ── */}
|
||||
{step === 2 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Summary card */}
|
||||
<div className="bg-[#F0F7FF] border border-[#D8E8FF] rounded-[12px] px-5 py-4 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-0.5 shrink-0">
|
||||
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Cohort Name</span>
|
||||
<span className="text-[14px] font-semibold leading-none text-[#032D20]">{stepOne.name}</span>
|
||||
</div>
|
||||
{stepOne.description && (
|
||||
<div className="flex flex-col gap-0.5 shrink-0 max-w-[280px]">
|
||||
<span className="text-[10px] font-semibold leading-none tracking-[1px] text-[#059669] uppercase">Description</span>
|
||||
<span className="text-[14px] font-semibold leading-none text-[#032D20] truncate">{stepOne.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<CustomStatus status={stepOne.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passenger Attributes */}
|
||||
<CustomAccordionSection
|
||||
icon={<User size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Passenger Attributes"
|
||||
isOpen={openSection === 'passenger'}
|
||||
hasData={passengerHasData}
|
||||
onToggle={() => toggleSection('passenger')}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Cabin Class"
|
||||
options={toDropdownOptions(cabinClasses)}
|
||||
value={stepTwo.cabinClassIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, cabinClassIds: val as string[] })}
|
||||
placeholder="Select Cabin Classes"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomMultiSelect
|
||||
label="Passenger Type"
|
||||
options={toDropdownOptions(passengerTypes)}
|
||||
value={stepTwo.passengerTypeIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, passengerTypeIds: val as string[] })}
|
||||
placeholder="Select Passenger Types"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomMultiSelect
|
||||
label="Ancillary Purchase"
|
||||
options={toDropdownOptions(ancillaryPurchases)}
|
||||
value={stepTwo.ancillaryPurchaseIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, ancillaryPurchaseIds: val as string[] })}
|
||||
placeholder="Select Ancillary Purchases"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
</CustomAccordionSection>
|
||||
|
||||
{/* Customer Value */}
|
||||
<CustomAccordionSection
|
||||
icon={<CircleCheck size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Customer Value"
|
||||
isOpen={openSection === 'customer'}
|
||||
hasData={customerHasData}
|
||||
onToggle={() => toggleSection('customer')}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Loyalty Tier"
|
||||
options={toDropdownOptions(membershipTiers)}
|
||||
value={stepTwo.loyaltyTierIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, loyaltyTierIds: val as string[] })}
|
||||
placeholder="Select Loyalty Tiers"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomMultiSelect
|
||||
label="Revenue Segment"
|
||||
options={toDropdownOptions(revenueSegments)}
|
||||
value={stepTwo.revenueSegmentIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, revenueSegmentIds: val as string[] })}
|
||||
placeholder="Select Revenue Segments"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-gray-900">High Value Passenger</span>
|
||||
<div className="flex items-center gap-7">
|
||||
{HIGH_VALUE_OPTIONS.map(opt => (
|
||||
<CustomRadio
|
||||
key={opt.value}
|
||||
name="highValuePassenger"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
checked={stepTwo.highValuePassenger === opt.value}
|
||||
onChange={() => setStepTwo({ ...stepTwo, highValuePassenger: opt.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomAccordionSection>
|
||||
|
||||
{/* Journey Context */}
|
||||
<CustomAccordionSection
|
||||
icon={<Globe size={15} className="text-gray-700" strokeWidth={2} />}
|
||||
title="Journey Context"
|
||||
isOpen={openSection === 'journey'}
|
||||
hasData={journeyHasData}
|
||||
onToggle={() => toggleSection('journey')}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-sm font-medium text-gray-900">Flight Type</span>
|
||||
<div className="flex items-center gap-7">
|
||||
<CustomCheckBox
|
||||
label="Domestic Only"
|
||||
checked={stepTwo.flightType === 'Domestic Only'}
|
||||
onChange={e => setStepTwo({ ...stepTwo, flightType: e.target.checked ? 'Domestic Only' : '' })}
|
||||
/>
|
||||
<CustomCheckBox
|
||||
label="International Only"
|
||||
checked={stepTwo.flightType === 'International Only'}
|
||||
onChange={e => setStepTwo({ ...stepTwo, flightType: e.target.checked ? 'International Only' : '' })}
|
||||
/>
|
||||
<CustomCheckBox
|
||||
label="Both (All)"
|
||||
checked={stepTwo.flightType === 'Both (All)'}
|
||||
onChange={e => setStepTwo({ ...stepTwo, flightType: e.target.checked ? 'Both (All)' : '' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<CustomMultiSelect
|
||||
label="Region"
|
||||
options={toDropdownOptions(regions)}
|
||||
value={stepTwo.regionIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, regionIds: val as string[] })}
|
||||
placeholder="Select Regions"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<CustomMultiSelect
|
||||
label="Trip Purpose"
|
||||
options={toDropdownOptions(tripPurposes)}
|
||||
value={stepTwo.tripPurposeIds}
|
||||
onChange={val => setStepTwo({ ...stepTwo, tripPurposeIds: val as string[] })}
|
||||
placeholder="Select Trip Purposes"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<CustomDropdown
|
||||
label="Scope type"
|
||||
options={SCOPE_OPTIONS}
|
||||
value={stepTwo.scopeType}
|
||||
onChange={val => {
|
||||
setStepTwo({
|
||||
...stepTwo,
|
||||
scopeType: val as string,
|
||||
originAirports: [],
|
||||
destinationAirports: []
|
||||
});
|
||||
}}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stepTwo.scopeType === 'Destination (Any Source)' ? (
|
||||
<CustomDropdown
|
||||
label="Origin Airport (IATA)"
|
||||
options={[{ label: 'Any Source', value: 'Any' }]}
|
||||
value={'Any'}
|
||||
onChange={() => {}}
|
||||
placeholder="Any Source"
|
||||
disabled
|
||||
/>
|
||||
) : stepTwo.scopeType === 'Route (One Origin to Many Destinations)' ? (
|
||||
<CustomDropdown
|
||||
label="Origin Airport (IATA)"
|
||||
options={AIRPORTS}
|
||||
value={stepTwo.originAirports[0] || ''}
|
||||
onChange={val => setStepTwo({ ...stepTwo, originAirports: [val as string] })}
|
||||
placeholder="Select Origin"
|
||||
/>
|
||||
) : (
|
||||
<CustomMultiSelect
|
||||
label="Origin Airport (IATA)"
|
||||
options={AIRPORTS}
|
||||
value={stepTwo.originAirports}
|
||||
onChange={val => setStepTwo({ ...stepTwo, originAirports: val as string[] })}
|
||||
placeholder="Select Origins"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{stepTwo.scopeType === 'Source (Any Destination)' ? (
|
||||
<CustomDropdown
|
||||
label="Destination Airport (IATA)"
|
||||
options={[{ label: 'Any Destination', value: 'Any' }]}
|
||||
value={'Any'}
|
||||
onChange={() => {}}
|
||||
placeholder="Any Destination"
|
||||
disabled
|
||||
/>
|
||||
) : stepTwo.scopeType === 'Route (Many Destinations to One Origin)' ? (
|
||||
<CustomDropdown
|
||||
label="Destination Airport (IATA)"
|
||||
options={AIRPORTS}
|
||||
value={stepTwo.destinationAirports[0] || ''}
|
||||
onChange={val => setStepTwo({ ...stepTwo, destinationAirports: [val as string] })}
|
||||
placeholder="Select Destination"
|
||||
/>
|
||||
) : (
|
||||
<CustomMultiSelect
|
||||
label="Destination Airport (IATA)"
|
||||
options={AIRPORTS}
|
||||
value={stepTwo.destinationAirports}
|
||||
onChange={val => setStepTwo({ ...stepTwo, destinationAirports: val as string[] })}
|
||||
placeholder="Select Destinations"
|
||||
disabled={masterDataLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CustomAccordionSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CustomModal>
|
||||
|
||||
<CustomSuccessModal
|
||||
isOpen={showSuccessModal}
|
||||
onClose={() => {
|
||||
setShowSuccessModal(false);
|
||||
handleClose();
|
||||
onSuccess();
|
||||
}}
|
||||
title={isEditMode ? "Cohort Updated Successfully." : "Cohort Created Successfully."}
|
||||
cohortName={stepOne.name}
|
||||
cohortStatus={stepOne.status}
|
||||
cohortDescription={stepOne.description}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AddCohart from './AddCohart';
|
||||
import { Plus, Trash2, Search, Check, X, Pencil } from 'lucide-react';
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomButton,
|
||||
CustomStatus,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomConfirmationModal,
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import {
|
||||
listCoharts,
|
||||
deleteCohart,
|
||||
updateCohartStatus,
|
||||
} from '../CohartManageApi';
|
||||
import type { CohartResponse } from '../CohartManageTypes';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'All Statuses', value: '' },
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Inactive', value: 'Inactive' },
|
||||
{ label: 'Draft', value: 'Draft' },
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function HeaderLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">{text}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellText({ text }: { text: string | null | undefined }) {
|
||||
return (
|
||||
<span style={{ fontSize: '14px', color: '#676767', fontWeight: 500 }}>
|
||||
{text || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CohortList() {
|
||||
const [cohorts, setCohorts] = useState<CohartResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Server-side pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Client-side filters (applied on top of server data)
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [deleteTarget, setDeleteTarget] = useState<CohartResponse | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<CohartResponse | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<CohartResponse | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
|
||||
// ─── Fetch cohorts from server ─────────────────────────────────────────────
|
||||
|
||||
const fetchCohorts = useCallback(async (page: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listCoharts(page, PAGE_SIZE);
|
||||
setCohorts(res.data);
|
||||
setTotalItems(res.total);
|
||||
setTotalPages(res.totalPages);
|
||||
} catch {
|
||||
setError('Failed to load cohorts. Please try again.');
|
||||
setCohorts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCohorts(currentPage);
|
||||
}, [currentPage, fetchCohorts]);
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleStatusFilter = (val: string) => {
|
||||
setStatusFilter(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteCohart(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
fetchCohorts(currentPage);
|
||||
} catch {
|
||||
setError('Failed to delete cohort. Please try again.');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!deactivateTarget) return;
|
||||
setStatusLoading(true);
|
||||
try {
|
||||
const newStatus = deactivateTarget.status === 'Active' ? 'Inactive' : 'Active';
|
||||
await updateCohartStatus(deactivateTarget.id, { status: newStatus });
|
||||
setDeactivateTarget(null);
|
||||
fetchCohorts(currentPage);
|
||||
} catch {
|
||||
setError('Failed to update cohort status. Please try again.');
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Client-side filter on top of server data ──────────────────────────────
|
||||
|
||||
const displayedCohorts = cohorts
|
||||
.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
.filter(c => (statusFilter ? c.status === statusFilter : true));
|
||||
|
||||
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||
|
||||
// ─── Table columns ─────────────────────────────────────────────────────────
|
||||
|
||||
const columns: Column<CohartResponse>[] = [
|
||||
{
|
||||
header: <HeaderLabel text="Cohort Name" />,
|
||||
accessor: row => (
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
||||
{row.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Description" />,
|
||||
accessor: row => <CellText text={row.description} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Status" />,
|
||||
accessor: row => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
className: 'text-right',
|
||||
accessor: row => (
|
||||
<CustomActionMenu>
|
||||
{row.status !== 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<Check size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
)}
|
||||
{row.status === 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() => setEditTarget(row)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Trash2 size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Loading skeleton ──────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<Skeleton width={320} height={36} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton width={140} height={36} />
|
||||
<Skeleton width={148} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height={52} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<CustomAlertBanner
|
||||
message={error}
|
||||
type="error"
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomTable<CohartResponse>
|
||||
columns={columns}
|
||||
data={displayedCohorts}
|
||||
leftHeaderActions={
|
||||
<div className="w-[380px]">
|
||||
<CustomInput
|
||||
placeholder="Search cohorts..."
|
||||
value={search}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
leftIcon={<Search size={16} />}
|
||||
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightHeaderActions={
|
||||
<>
|
||||
<div className="w-44">
|
||||
<CustomDropdown
|
||||
options={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={handleStatusFilter}
|
||||
placeholder="All Statuses"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Plus size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px]"
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
>
|
||||
Create Cohort
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={handlePageChange}
|
||||
itemName="cohorts"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Cohort"
|
||||
description={`"${deleteTarget?.name}" will be permanently removed and cannot be recovered.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
|
||||
{/* Activate / Deactivate Confirmation */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deactivateTarget}
|
||||
onClose={() => setDeactivateTarget(null)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={deactivateTarget?.status === 'Active' ? 'Deactivate Cohort' : 'Activate Cohort'}
|
||||
description={
|
||||
deactivateTarget?.status === 'Active'
|
||||
? `"${deactivateTarget?.name}" will be deactivated and removed from active targeting.`
|
||||
: `"${deactivateTarget?.name}" will be reactivated and available for targeting.`
|
||||
}
|
||||
confirmText={deactivateTarget?.status === 'Active' ? 'Deactivate' : 'Activate'}
|
||||
cancelText="Cancel"
|
||||
variant="warning"
|
||||
isLoading={statusLoading}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<AddCohart
|
||||
isOpen={isAddOpen}
|
||||
onClose={() => setIsAddOpen(false)}
|
||||
onSuccess={() => {
|
||||
setIsAddOpen(false);
|
||||
if (currentPage === 1) fetchCohorts(1);
|
||||
else setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editTarget && (
|
||||
<AddCohart
|
||||
isOpen={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onSuccess={() => { setEditTarget(null); fetchCohorts(currentPage); }}
|
||||
editData={editTarget}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, Plus, ChevronLeft, ChevronRight, MoreHorizontal, Eye, Edit, Trash2 } from 'lucide-react';
|
||||
import AddCohart from './components/AddCohart';
|
||||
const INITIAL_COHORTS = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Strategic Accounts',
|
||||
description: 'Key corporate account travelers.',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
modifiedBy: 'John Doe',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Families with Infants',
|
||||
description: 'Passengers traveling with children < 2yrs.',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
modifiedBy: 'John Doe',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Strategic Accounts',
|
||||
description: 'Key corporate account travelers.',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
modifiedBy: 'John Doe',
|
||||
},
|
||||
];
|
||||
import CohortList from './components/cohartList';
|
||||
|
||||
export default function CohortManage() {
|
||||
const [cohorts, setCohorts] = useState(INITIAL_COHORTS);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [actionMenuOpen, setActionMenuOpen] = useState<number | null>(null);
|
||||
|
||||
const handleAddCohort = (newCohort: any) => {
|
||||
setCohorts([newCohort, ...cohorts]);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setCohorts(cohorts.filter(c => c.id !== id));
|
||||
setActionMenuOpen(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 flex flex-col min-h-[600px]">
|
||||
{/* Top Controls */}
|
||||
<div className="p-4 border-b border-gray-100 flex items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search cohorts..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-50 border-none rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<select className="px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm text-gray-600 focus:outline-none focus:border-gray-300">
|
||||
<option>Choose Status</option>
|
||||
<option>Active</option>
|
||||
<option>Inactive</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => setIsAddOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#1B9869] hover:bg-[#157a54] text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Create Cohort
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 w-[20%]">Cohort Name</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 w-[30%]">Description</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 w-[15%]">Status</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 w-[15%]">Last Modified</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 w-[15%]">Last Modified By</th>
|
||||
<th className="px-6 py-4 text-xs font-semibold text-gray-500 text-center w-[5%]">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cohorts.map((cohort) => (
|
||||
<tr key={cohort.id} className="border-b border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-bold text-gray-900">{cohort.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{cohort.description}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-green-50 text-green-700 text-xs font-medium border border-green-100">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
{cohort.status}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{cohort.lastModified}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{cohort.modifiedBy}</td>
|
||||
<td className="px-6 py-4 text-center relative">
|
||||
<button
|
||||
onClick={() => setActionMenuOpen(actionMenuOpen === cohort.id ? null : cohort.id)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-2 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
|
||||
{actionMenuOpen === cohort.id && (
|
||||
<div className="absolute right-8 top-10 w-48 bg-white border border-gray-100 shadow-xl rounded-xl z-10 py-2 overflow-hidden">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors text-left font-medium">
|
||||
<Eye size={16} className="text-[#3B82F6]" />
|
||||
View Audience
|
||||
</button>
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors text-left font-medium">
|
||||
<Edit size={16} className="text-yellow-500" />
|
||||
Edit Targeting
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cohort.id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors text-left font-medium"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete Permanently
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="p-4 border-t border-gray-100 flex items-center justify-between bg-gray-50/50 rounded-b-xl">
|
||||
<span className="text-sm text-gray-500">Showing 1 to 5 of 128 orders</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-100 transition-colors bg-white">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded border border-[#1B9869] bg-[#1B9869] text-white text-sm font-medium transition-colors">
|
||||
1
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded border border-gray-200 text-gray-600 hover:bg-gray-100 transition-colors bg-white text-sm">
|
||||
2
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded border border-gray-200 text-gray-600 hover:bg-gray-100 transition-colors bg-white text-sm">
|
||||
3
|
||||
</button>
|
||||
<button className="w-8 h-8 flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-100 transition-colors bg-white">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddCohart
|
||||
isOpen={isAddOpen}
|
||||
onClose={() => setIsAddOpen(false)}
|
||||
onAdd={handleAddCohort}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <CohortList />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import type { MasterDataItem, MasterDataCategory } from './MasterDataTypes';
|
||||
|
||||
// ─── Generic fetch for any category ─────────────────────────────────────────
|
||||
|
||||
export function getMasterDataByCategory(category: MasterDataCategory): Promise<MasterDataItem[]> {
|
||||
return ApiClient.get<any, MasterDataItem[]>(`/master-data/${category}`);
|
||||
}
|
||||
|
||||
// ─── Individual category helpers ─────────────────────────────────────────────
|
||||
|
||||
export function getMembershipTiers(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('MEMBERSHIP_TIER');
|
||||
}
|
||||
|
||||
export function getCustomerValues(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('CUSTOMER_VALUE');
|
||||
}
|
||||
|
||||
export function getRegions(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('REGION');
|
||||
}
|
||||
|
||||
export function getTripPurposes(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('TRIP_PURPOSE');
|
||||
}
|
||||
|
||||
export function getCabinClasses(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('CABIN_CLASS');
|
||||
}
|
||||
|
||||
export function getPassengerTypes(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('PASSENGER_TYPE');
|
||||
}
|
||||
|
||||
export function getAncillaryPurchases(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('ANCILLARY_PURCHASE');
|
||||
}
|
||||
|
||||
export function getRevenueSegments(): Promise<MasterDataItem[]> {
|
||||
return getMasterDataByCategory('REVENUE_SEGMENT');
|
||||
}
|
||||
|
||||
// ─── Fetch all 8 categories at once ─────────────────────────────────────────
|
||||
|
||||
export interface AllMasterData {
|
||||
membershipTiers: MasterDataItem[];
|
||||
customerValues: MasterDataItem[];
|
||||
regions: MasterDataItem[];
|
||||
tripPurposes: MasterDataItem[];
|
||||
cabinClasses: MasterDataItem[];
|
||||
passengerTypes: MasterDataItem[];
|
||||
ancillaryPurchases: MasterDataItem[];
|
||||
revenueSegments: MasterDataItem[];
|
||||
}
|
||||
|
||||
export async function getAllMasterData(): Promise<AllMasterData> {
|
||||
const [
|
||||
membershipTiers,
|
||||
customerValues,
|
||||
regions,
|
||||
tripPurposes,
|
||||
cabinClasses,
|
||||
passengerTypes,
|
||||
ancillaryPurchases,
|
||||
revenueSegments,
|
||||
] = await Promise.all([
|
||||
getMembershipTiers(),
|
||||
getCustomerValues(),
|
||||
getRegions(),
|
||||
getTripPurposes(),
|
||||
getCabinClasses(),
|
||||
getPassengerTypes(),
|
||||
getAncillaryPurchases(),
|
||||
getRevenueSegments(),
|
||||
]);
|
||||
|
||||
return {
|
||||
membershipTiers,
|
||||
customerValues,
|
||||
regions,
|
||||
tripPurposes,
|
||||
cabinClasses,
|
||||
passengerTypes,
|
||||
ancillaryPurchases,
|
||||
revenueSegments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// ─── Master Data Item ───────────────────────────────────────────────────────
|
||||
|
||||
export interface MasterDataItem {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Category Keys ──────────────────────────────────────────────────────────
|
||||
|
||||
export type MasterDataCategory =
|
||||
| 'MEMBERSHIP_TIER'
|
||||
| 'CUSTOMER_VALUE'
|
||||
| 'REGION'
|
||||
| 'TRIP_PURPOSE'
|
||||
| 'CABIN_CLASS'
|
||||
| 'PASSENGER_TYPE'
|
||||
| 'ANCILLARY_PURCHASE'
|
||||
| 'REVENUE_SEGMENT';
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface PolicyEngineResponse {
|
||||
id: string;
|
||||
policyName: string;
|
||||
jurisdiction: string;
|
||||
status: 'Active' | 'Inactive' | 'Draft';
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface PaginatedPolicyEngineResponse {
|
||||
data: PolicyEngineResponse[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, Minus, Book, Users, GitBranch, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CustomInput,
|
||||
CustomDropdown,
|
||||
CustomButton,
|
||||
CustomTextArea,
|
||||
CustomStatus
|
||||
} from '../../../components/custom';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Condition = {
|
||||
id: string;
|
||||
condition: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
logic: string;
|
||||
};
|
||||
|
||||
type Action = {
|
||||
id: string;
|
||||
actionType: string;
|
||||
roomTypeTier: string;
|
||||
duration: string;
|
||||
logic: string;
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
id: string;
|
||||
category: string;
|
||||
priority: number;
|
||||
conditions: Condition[];
|
||||
actions: Action[];
|
||||
};
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const JURISDICTION_OPTIONS = [
|
||||
{ label: 'GLOBAL', value: 'GLOBAL' },
|
||||
{ label: 'US', value: 'US' },
|
||||
{ label: 'EU', value: 'EU' },
|
||||
];
|
||||
|
||||
const COHORT_OPTIONS = [
|
||||
{ label: 'Premium Members', value: 'Premium Members' },
|
||||
{ label: 'Frequent Flyers', value: 'Frequent Flyers' },
|
||||
{ label: 'Families', value: 'Families' },
|
||||
];
|
||||
|
||||
const RULE_CATEGORY_OPTIONS = [
|
||||
{ label: 'Compensation', value: 'Compensation' },
|
||||
{ label: 'Accommodation', value: 'Accommodation' },
|
||||
{ label: 'Rebooking', value: 'Rebooking' },
|
||||
];
|
||||
|
||||
const CONDITION_OPTIONS = [
|
||||
{ label: 'Delay Duration', value: 'Delay Duration' },
|
||||
{ label: 'Flight Distance', value: 'Flight Distance' },
|
||||
{ label: 'Passenger Tier', value: 'Passenger Tier' },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ label: 'Greater Than', value: 'Greater Than' },
|
||||
{ label: 'Less Than', value: 'Less Than' },
|
||||
{ label: 'Equals', value: 'Equals' },
|
||||
];
|
||||
|
||||
const ACTION_TYPE_OPTIONS = [
|
||||
{ label: 'Hotel Booking', value: 'Hotel Booking' },
|
||||
{ label: 'Voucher', value: 'Voucher' },
|
||||
{ label: 'Lounge Access', value: 'Lounge Access' },
|
||||
];
|
||||
|
||||
const ROOM_TYPE_TIER_OPTIONS = [
|
||||
{ label: 'Standard', value: 'Standard' },
|
||||
{ label: 'Premium', value: 'Premium' },
|
||||
{ label: 'Suite', value: 'Suite' },
|
||||
];
|
||||
|
||||
const LOGIC_OPTIONS = [
|
||||
{ label: 'AND', value: 'AND' },
|
||||
{ label: 'OR', value: 'OR' },
|
||||
{ label: 'NOT', value: 'NOT' },
|
||||
];
|
||||
|
||||
// Per-gate color scheme (exact Figma tokens): AND=blue, OR=yellow, NOT=red.
|
||||
// Fill = background per gate; border per gate; label/chevron text = #032D20 for all.
|
||||
const LOGIC_STYLES: Record<string, { bg: string; text: string; border: string }> = {
|
||||
AND: { bg: 'bg-[#F2FAFF]', text: 'text-[#032D20]', border: 'border-[#1A6597]' },
|
||||
OR: { bg: 'bg-[#FFFDF2]', text: 'text-[#032D20]', border: 'border-[#977E1A]' },
|
||||
NOT: { bg: 'bg-[#FFF2F2]', text: 'text-[#032D20]', border: 'border-[#971A1C]' },
|
||||
};
|
||||
|
||||
// ─── Logic Gate Dropdown ─────────────────────────────────────────────────────
|
||||
// Colored variant of the logic selector — the shared CustomDropdown hardcodes a
|
||||
// white background and text color, so we render a dedicated colored control here.
|
||||
|
||||
function LogicDropdown({ value, onChange, readOnly = false }: { value: string; onChange?: (v: string) => void; readOnly?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [readOnly]);
|
||||
|
||||
const style = LOGIC_STYLES[value] ?? LOGIC_STYLES.AND;
|
||||
const boxClass = `w-full h-[50px] px-4 rounded-[10px] border flex items-center justify-between gap-2 text-[14px] font-semibold transition-colors shadow-[0_1px_2px_0_rgba(0,0,0,0.05)] ${style.bg} ${style.text} ${style.border}`;
|
||||
|
||||
// Fixed / non-editable gate (used by the Strategic Action Builder) — no menu.
|
||||
if (readOnly) {
|
||||
return <div className={boxClass}>{value}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={boxClass}
|
||||
>
|
||||
{value}
|
||||
<ChevronDown size={16} className={`transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden">
|
||||
{LOGIC_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange?.(opt.value); setOpen(false); }}
|
||||
className="w-full text-left px-4 h-[42px] text-[14px] font-medium flex items-center text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddPolicyEngine() {
|
||||
const navigate = useNavigate();
|
||||
// Policy Info State
|
||||
const [policyName, setPolicyName] = useState('');
|
||||
const [jurisdiction, setJurisdiction] = useState('');
|
||||
const [status, setStatus] = useState<'Active' | 'Inactive'>('Active');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Target Audience State
|
||||
const [audienceType, setAudienceType] = useState<'All Passengers' | 'Selected Cohorts'>('All Passengers');
|
||||
const [selectedCohort, setSelectedCohort] = useState('');
|
||||
|
||||
// Rule Engine State
|
||||
const [rules, setRules] = useState<Rule[]>([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
category: '',
|
||||
priority: 2,
|
||||
conditions: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }],
|
||||
actions: [{ id: crypto.randomUUID(), actionType: '', roomTypeTier: '', duration: '', logic: 'AND' }]
|
||||
}
|
||||
]);
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Returns the lowest priority (1-10) not already used by an existing rule.
|
||||
const findFreePriority = () => {
|
||||
const taken = rules.map(r => r.priority);
|
||||
for (let p = 1; p <= 10; p++) {
|
||||
if (!taken.includes(p)) return p;
|
||||
}
|
||||
return 1; // all 10 slots taken (>10 rules) — warning handles this edge case
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
setRules([...rules, {
|
||||
id: crypto.randomUUID(),
|
||||
category: '',
|
||||
priority: findFreePriority(),
|
||||
conditions: [{ id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }],
|
||||
actions: [{ id: crypto.randomUUID(), actionType: '', roomTypeTier: '', duration: '', logic: 'AND' }]
|
||||
}]);
|
||||
};
|
||||
|
||||
const handleDeleteRule = (ruleId: string) => {
|
||||
setRules(rules.filter(r => r.id !== ruleId));
|
||||
};
|
||||
|
||||
const handleUpdateRule = (ruleId: string, updates: Partial<Rule>) => {
|
||||
setRules(rules.map(r => r.id === ruleId ? { ...r, ...updates } : r));
|
||||
};
|
||||
|
||||
// Priorities used by every OTHER rule — a rule may never land on one of these.
|
||||
const priorityTakenByOthers = (rule: Rule) =>
|
||||
rules.filter(r => r.id !== rule.id).map(r => r.priority);
|
||||
|
||||
// Next free priority below the current value (down to 1), or null if none.
|
||||
const getPrevPriority = (rule: Rule): number | null => {
|
||||
const taken = priorityTakenByOthers(rule);
|
||||
for (let p = rule.priority - 1; p >= 1; p--) {
|
||||
if (!taken.includes(p)) return p;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Next free priority above the current value (up to 10), or null if none.
|
||||
const getNextPriority = (rule: Rule): number | null => {
|
||||
const taken = priorityTakenByOthers(rule);
|
||||
for (let p = rule.priority + 1; p <= 10; p++) {
|
||||
if (!taken.includes(p)) return p;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAddCondition = (ruleId: string) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return {
|
||||
...r,
|
||||
conditions: [...r.conditions, { id: crypto.randomUUID(), condition: '', operator: '', value: '', logic: 'AND' }]
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdateCondition = (ruleId: string, conditionId: string, updates: Partial<Condition>) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return {
|
||||
...r,
|
||||
conditions: r.conditions.map(c => c.id === conditionId ? { ...c, ...updates } : c)
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteCondition = (ruleId: string, conditionId: string) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return { ...r, conditions: r.conditions.filter(c => c.id !== conditionId) };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddAction = (ruleId: string) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return {
|
||||
...r,
|
||||
actions: [...r.actions, { id: crypto.randomUUID(), actionType: '', roomTypeTier: '', duration: '', logic: 'AND' }]
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdateAction = (ruleId: string, actionId: string, updates: Partial<Action>) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return {
|
||||
...r,
|
||||
actions: r.actions.map(a => a.id === actionId ? { ...a, ...updates } : a)
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteAction = (ruleId: string, actionId: string) => {
|
||||
setRules(rules.map(r => {
|
||||
if (r.id === ruleId) {
|
||||
return { ...r, actions: r.actions.filter(a => a.id !== actionId) };
|
||||
}
|
||||
return r;
|
||||
}));
|
||||
};
|
||||
|
||||
// ─── Render Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const CardHeader = ({ icon: Icon, title }: { icon: any, title: string }) => (
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#E8F3EF] flex items-center justify-center text-[#1E7D5C]">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-[#0F172B]">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white min-h-screen">
|
||||
|
||||
{/* ─── Header ────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-8 py-4 bg-white border-b border-gray-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/policy-engine')}
|
||||
className="mt-1 p-1 hover:bg-gray-100 rounded-full transition-colors text-gray-500"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-xl font-bold text-[#0F172B]">Deploy New Policy</h1>
|
||||
<span className="text-[13px] text-gray-500">Global Framework Registry</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] !h-10 !px-4 !rounded-[10px]"
|
||||
leftIcon={<Plus size={16} />}
|
||||
>
|
||||
Deploy Policy
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Body Content ──────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6 pb-32">
|
||||
<div className="max-w-[1200px] mx-auto flex flex-col gap-6">
|
||||
|
||||
{/* Policy Information Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<CardHeader icon={Book} title="Policy Information" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Policy Name<span className="text-red-500">*</span></label>
|
||||
<CustomInput
|
||||
value={policyName}
|
||||
onChange={(e) => setPolicyName(e.target.value)}
|
||||
placeholder="Selected Option"
|
||||
className="!h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Jurisdiction</label>
|
||||
<CustomDropdown
|
||||
options={JURISDICTION_OPTIONS}
|
||||
value={jurisdiction}
|
||||
onChange={setJurisdiction}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Status</label>
|
||||
<div className="flex items-center gap-6 h-11">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
checked={status === 'Active'}
|
||||
onChange={() => setStatus('Active')}
|
||||
className="w-4 h-4 text-[#1E7D5C] focus:ring-[#1E7D5C]"
|
||||
/>
|
||||
<span className="text-[14px] text-gray-700">Active</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
checked={status === 'Inactive'}
|
||||
onChange={() => setStatus('Inactive')}
|
||||
className="w-4 h-4 text-[#1E7D5C] focus:ring-[#1E7D5C]"
|
||||
/>
|
||||
<span className="text-[14px] text-gray-700">Inactive</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Description</label>
|
||||
<CustomTextArea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter description..."
|
||||
className="!h-24 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Audience Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<CardHeader icon={Users} title="Target Audience" />
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="audience"
|
||||
checked={audienceType === 'All Passengers'}
|
||||
onChange={() => setAudienceType('All Passengers')}
|
||||
className="w-4 h-4 text-[#1E7D5C] focus:ring-[#1E7D5C]"
|
||||
/>
|
||||
<span className="text-[14px] font-medium text-gray-700">All Passengers</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="audience"
|
||||
checked={audienceType === 'Selected Cohorts'}
|
||||
onChange={() => setAudienceType('Selected Cohorts')}
|
||||
className="w-4 h-4 text-[#1E7D5C] focus:ring-[#1E7D5C]"
|
||||
/>
|
||||
<span className="text-[14px] font-medium text-gray-700">Selected Cohorts</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{audienceType === 'Selected Cohorts' && (
|
||||
<div className="w-1/3 flex flex-col gap-2">
|
||||
<label className="text-[13px] font-semibold text-gray-700">Cohort</label>
|
||||
<CustomDropdown
|
||||
options={COHORT_OPTIONS}
|
||||
value={selectedCohort}
|
||||
onChange={setSelectedCohort}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule Engine Card */}
|
||||
<div className="bg-[#FAFAFA] rounded-[16px] p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<CardHeader icon={GitBranch} title="Rule Engine" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddRule}
|
||||
className="flex items-center justify-center gap-2.5 min-w-[181px] h-10 px-4 rounded-[12px] whitespace-nowrap transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
border: '2px solid transparent',
|
||||
backgroundImage: 'linear-gradient(#fff, #fff), linear-gradient(180deg, #1B9869 0%, #14704E 100%)',
|
||||
backgroundOrigin: 'border-box',
|
||||
backgroundClip: 'padding-box, border-box',
|
||||
}}
|
||||
>
|
||||
<Plus size={16} color="#14704E" />
|
||||
<span className="font-bold text-[14px] leading-none bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
|
||||
Add Strategic Rule
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{rules.map((rule, ruleIndex) => (
|
||||
<div key={rule.id} className="border border-gray-100 rounded-[12px] p-6 bg-white relative">
|
||||
|
||||
{/* Rule Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-base font-bold text-[#0F172B]">Rule {ruleIndex + 1}</h3>
|
||||
{rules.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="p-1.5 text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rule Settings */}
|
||||
<div className="flex items-end gap-6 mb-8">
|
||||
<div className="w-1/3 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Rule Category</label>
|
||||
<CustomDropdown
|
||||
options={RULE_CATEGORY_OPTIONS}
|
||||
value={rule.category}
|
||||
onChange={(val) => handleUpdateRule(rule.id, { category: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Priority</label>
|
||||
{(() => {
|
||||
const prevPriority = getPrevPriority(rule);
|
||||
const nextPriority = getNextPriority(rule);
|
||||
return (
|
||||
<div className="flex items-center border border-gray-200 rounded-[10px] h-11 bg-white overflow-hidden w-[120px]">
|
||||
<button
|
||||
onClick={() => prevPriority !== null && handleUpdateRule(rule.id, { priority: prevPriority })}
|
||||
disabled={prevPriority === null}
|
||||
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<span className="flex-1 text-center text-[14px] font-semibold text-gray-800">
|
||||
{rule.priority}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => nextPriority !== null && handleUpdateRule(rule.id, { priority: nextPriority })}
|
||||
disabled={nextPriority === null}
|
||||
className="flex-1 flex items-center justify-center h-full hover:bg-gray-50 text-gray-500 disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{rules.some(r => r.id !== rule.id && r.priority === rule.priority) && (
|
||||
<span className="text-red-500 text-xs mt-1">Priority already exists</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Condition Builder */}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Visual Condition Builder</h4>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rule.conditions.map((condition, cIdx) => (
|
||||
<div key={condition.id} className="flex items-end gap-3">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Condition</label>
|
||||
<CustomDropdown
|
||||
options={CONDITION_OPTIONS}
|
||||
value={condition.condition}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { condition: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Operator</label>
|
||||
<CustomDropdown
|
||||
options={OPERATOR_OPTIONS}
|
||||
value={condition.operator}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { operator: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Value</label>
|
||||
<CustomInput
|
||||
value={condition.value}
|
||||
onChange={(e) => handleUpdateCondition(rule.id, condition.id, { value: e.target.value })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
{cIdx < rule.conditions.length - 1 ? (
|
||||
<div className="w-[163px] flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
|
||||
<LogicDropdown
|
||||
value={condition.logic}
|
||||
onChange={(val) => handleUpdateCondition(rule.id, condition.id, { logic: val })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[163px] flex items-center">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
|
||||
leftIcon={<Plus size={16} color="#14704E" />}
|
||||
onClick={() => handleAddCondition(rule.id)}
|
||||
>
|
||||
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
|
||||
Add Condition
|
||||
</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center h-11">
|
||||
{rule.conditions.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteCondition(rule.id, condition.id)}
|
||||
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} color="#D40000" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strategic Action Builder */}
|
||||
<div>
|
||||
<h4 className="text-[14px] font-bold text-[#0F172B] mb-4">Strategic Action Builder</h4>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rule.actions.map((action, aIdx) => (
|
||||
<div key={action.id} className="flex items-end gap-3">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Action Type</label>
|
||||
<CustomDropdown
|
||||
options={ACTION_TYPE_OPTIONS}
|
||||
value={action.actionType}
|
||||
onChange={(val) => handleUpdateAction(rule.id, action.id, { actionType: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Room Type / Tier</label>
|
||||
<CustomDropdown
|
||||
options={ROOM_TYPE_TIER_OPTIONS}
|
||||
value={action.roomTypeTier}
|
||||
onChange={(val) => handleUpdateAction(rule.id, action.id, { roomTypeTier: val })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Duration</label>
|
||||
<CustomInput
|
||||
value={action.duration}
|
||||
onChange={(e) => handleUpdateAction(rule.id, action.id, { duration: e.target.value })}
|
||||
placeholder="Selected Option"
|
||||
/>
|
||||
</div>
|
||||
{aIdx < rule.actions.length - 1 ? (
|
||||
<div className="w-[163px] flex flex-col gap-2">
|
||||
<label className="text-[14px] font-medium leading-none text-[#001811]">Logic</label>
|
||||
<LogicDropdown value={action.logic} readOnly />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[163px] flex items-center">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!w-full !justify-center !bg-[#EAFAF5] hover:!bg-[#d9ece4] !px-4 !gap-2.5 !h-[49px] !rounded-[12px] whitespace-nowrap"
|
||||
leftIcon={<Plus size={16} color="#14704E" />}
|
||||
onClick={() => handleAddAction(rule.id)}
|
||||
>
|
||||
<span className="font-bold text-[14px] leading-none text-center bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent">
|
||||
Add
|
||||
</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center h-11">
|
||||
{rule.actions.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteAction(rule.id, action.id)}
|
||||
className="w-11 h-11 flex items-center justify-center bg-red-50 text-[#D40000] rounded-[10px] hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} color="#D40000" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Sticky Footer ─────────────────────────────────────────────── */}
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-8 py-4 flex items-center justify-between z-10 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[14px] font-semibold text-gray-600">Status:</span>
|
||||
<CustomStatus status={status} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<CustomButton
|
||||
variant="secondary"
|
||||
className="!text-[#1E7D5C] !bg-[#E8F3EF] hover:!bg-[#d9ece4] !border-none font-semibold px-6"
|
||||
>
|
||||
Save Draft
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
className="!border-[#1E7D5C] !text-[#1E7D5C] hover:!bg-gray-50 font-semibold px-6"
|
||||
onClick={() => navigate('/policy-engine')}
|
||||
>
|
||||
Cancel Policy
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
className="!bg-[#1E7D5C] hover:!bg-[#17664B] font-semibold px-6 shadow-sm"
|
||||
>
|
||||
Deploy Policy
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Trash2, Search, Check, X, Pencil, Copy } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
CustomTable,
|
||||
CustomInput,
|
||||
CustomButton,
|
||||
CustomStatus,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomConfirmationModal,
|
||||
Skeleton,
|
||||
} from '../../../components/custom';
|
||||
import type { Column } from '../../../components/custom/CustomTable';
|
||||
import type { PolicyEngineResponse } from '../PolicyEngineTypes';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// ─── Mock Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_POLICIES: PolicyEngineResponse[] = [
|
||||
{
|
||||
id: '1',
|
||||
policyName: 'New Recovery Strategy',
|
||||
jurisdiction: 'GLOBAL',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
policyName: 'EU261 Standard Recovery',
|
||||
jurisdiction: 'GLOBAL',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
policyName: 'US DOT Consumer Protection',
|
||||
jurisdiction: 'GLOBAL',
|
||||
status: 'Active',
|
||||
lastModified: '4 Jun 2026, 4:09pm',
|
||||
},
|
||||
// Add some more mock data to demonstrate pagination if needed
|
||||
...Array.from({ length: 9 }).map((_, i) => ({
|
||||
id: `mock-${i + 4}`,
|
||||
policyName: `Sample Policy ${i + 4}`,
|
||||
jurisdiction: 'GLOBAL',
|
||||
status: i % 2 === 0 ? 'Draft' : 'Inactive' as 'Active' | 'Inactive' | 'Draft',
|
||||
lastModified: '5 Jun 2026, 10:00am',
|
||||
}))
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function HeaderLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="text-[14px] font-semibold text-[#6C766D] tracking-[0px]">{text}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellText({ text }: { text: string | null | undefined }) {
|
||||
return (
|
||||
<span style={{ fontSize: '14px', color: '#676767', fontWeight: 500 }}>
|
||||
{text || '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeLabel({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-xs font-semibold">
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PolicyEngineList() {
|
||||
const navigate = useNavigate();
|
||||
const [policies, setPolicies] = useState<PolicyEngineResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [deleteTarget, setDeleteTarget] = useState<PolicyEngineResponse | null>(null);
|
||||
const [deactivateTarget, setDeactivateTarget] = useState<PolicyEngineResponse | null>(null);
|
||||
|
||||
// ─── Fetch data ─────────────────────────────────────────────
|
||||
|
||||
const fetchPolicies = useCallback((page: number) => {
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API call with timeout
|
||||
setTimeout(() => {
|
||||
const filteredData = MOCK_POLICIES.filter(p =>
|
||||
p.policyName.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const total = filteredData.length;
|
||||
const pages = Math.ceil(total / PAGE_SIZE);
|
||||
const start = (page - 1) * PAGE_SIZE;
|
||||
const paginatedData = filteredData.slice(start, start + PAGE_SIZE);
|
||||
|
||||
setPolicies(paginatedData);
|
||||
setTotalItems(total);
|
||||
setTotalPages(pages || 1);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger a new fetch when page or search changes
|
||||
fetchPolicies(currentPage);
|
||||
}, [currentPage, search, fetchPolicies]);
|
||||
|
||||
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const handleSearchChange = (val: string) => {
|
||||
setSearch(val);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
// Simulate delete
|
||||
console.log('Deleted policy:', deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!deactivateTarget) return;
|
||||
// Simulate toggle status
|
||||
console.log('Toggled status for policy:', deactivateTarget.id);
|
||||
setDeactivateTarget(null);
|
||||
fetchPolicies(currentPage);
|
||||
};
|
||||
|
||||
const startIndex = totalItems > 0 ? (currentPage - 1) * PAGE_SIZE + 1 : 0;
|
||||
const endIndex = Math.min(currentPage * PAGE_SIZE, totalItems);
|
||||
|
||||
// ─── Table columns ─────────────────────────────────────────────────────────
|
||||
|
||||
const columns: Column<PolicyEngineResponse>[] = [
|
||||
{
|
||||
header: <HeaderLabel text="Policy Name" />,
|
||||
accessor: row => (
|
||||
<span className="text-[13px] font-semibold text-[#0F172B] leading-[18px] tracking-[0px]">
|
||||
{row.policyName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Jurisdiction" />,
|
||||
accessor: row => <BadgeLabel text={row.jurisdiction} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Status" />,
|
||||
accessor: row => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Last Modified" />,
|
||||
accessor: row => <CellText text={row.lastModified} />,
|
||||
},
|
||||
{
|
||||
header: <HeaderLabel text="Action" />,
|
||||
className: 'text-right',
|
||||
accessor: row => (
|
||||
<CustomActionMenu>
|
||||
{row.status !== 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<Check size={15} />}
|
||||
variant="success"
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Activate
|
||||
</CustomActionItem>
|
||||
)}
|
||||
{row.status === 'Active' && (
|
||||
<CustomActionItem
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setDeactivateTarget(row)}
|
||||
>
|
||||
Deactivate
|
||||
</CustomActionItem>
|
||||
)}
|
||||
<CustomActionItem
|
||||
icon={<Copy size={15} />}
|
||||
onClick={() => console.log('Duplicate:', row.id)}
|
||||
>
|
||||
Duplicate
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Pencil size={15} />}
|
||||
onClick={() => console.log('Edit:', row.id)}
|
||||
>
|
||||
Edit
|
||||
</CustomActionItem>
|
||||
<CustomActionItem
|
||||
icon={<Trash2 size={15} />}
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
Delete
|
||||
</CustomActionItem>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Loading skeleton ──────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<Skeleton width={320} height={36} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton width={148} height={36} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} height={52} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomTable<PolicyEngineResponse>
|
||||
columns={columns}
|
||||
data={policies}
|
||||
leftHeaderActions={
|
||||
<div className="w-[380px]">
|
||||
<CustomInput
|
||||
placeholder="Search framework registry..."
|
||||
value={search}
|
||||
onChange={e => handleSearchChange(e.target.value)}
|
||||
leftIcon={<Search size={16} />}
|
||||
className="!bg-[#F3F6F5] !rounded-[10px] !h-[38px] !border !border-[#E5E7EB]"
|
||||
containerClassName="!gap-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
rightHeaderActions={
|
||||
<>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Plus size={16} />}
|
||||
className="!rounded-[10px] !gap-[10px] !h-[40px] !bg-[#1E7D5C] hover:!bg-[#17664B]"
|
||||
onClick={() => navigate('/policy-engine/add')}
|
||||
>
|
||||
Deploy New Policy
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
onPageChange={handlePageChange}
|
||||
itemName="Policies"
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Policy"
|
||||
description={`"${deleteTarget?.policyName}" will be permanently removed.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Activate / Deactivate Confirmation */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!deactivateTarget}
|
||||
onClose={() => setDeactivateTarget(null)}
|
||||
onConfirm={handleToggleStatus}
|
||||
title={deactivateTarget?.status === 'Active' ? 'Deactivate Policy' : 'Activate Policy'}
|
||||
description={
|
||||
deactivateTarget?.status === 'Active'
|
||||
? `"${deactivateTarget?.policyName}" will be deactivated.`
|
||||
: `"${deactivateTarget?.policyName}" will be reactivated.`
|
||||
}
|
||||
confirmText={deactivateTarget?.status === 'Active' ? 'Deactivate' : 'Activate'}
|
||||
cancelText="Cancel"
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
|
||||
interface CustomAccordionSectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
hasData: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CustomAccordionSection: React.FC<CustomAccordionSectionProps> = ({
|
||||
icon,
|
||||
title,
|
||||
isOpen,
|
||||
hasData,
|
||||
onToggle,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="border border-[#E3EDE5] rounded-[12px] bg-[#F8FAF8]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-4 py-3.5 text-left"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-[11px] font-bold text-gray-700 uppercase tracking-wider flex-1">{title}</span>
|
||||
{!isOpen && hasData && <CircleCheck size={18} className="text-[#1B9869]" strokeWidth={2} />}
|
||||
</button>
|
||||
{isOpen && <div className="px-4 pb-4 pt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAccordionSection;
|
||||
@@ -83,7 +83,7 @@ export const CustomActionMenu: React.FC<CustomActionMenuProps> = ({
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors focus:outline-none ${isOpen ? 'bg-gray-100 text-gray-900' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-900'}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors focus:outline-none ${isOpen ? 'bg-gray-100 text-black' : 'text-black hover:bg-gray-100'}`}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
@@ -126,13 +126,13 @@ export const CustomActionItem: React.FC<CustomActionItemProps> = ({
|
||||
|
||||
const variantClasses = {
|
||||
default: "text-gray-700 hover:bg-gray-50 hover:text-gray-900",
|
||||
success: "text-[#1B9869] bg-[#EBF7F2] hover:brightness-95",
|
||||
danger: "text-red-600 hover:bg-red-50 hover:text-red-700",
|
||||
success: "text-primary bg-[#EBF7F2] hover:brightness-95",
|
||||
danger: "text-red-600 bg-red-50 hover:brightness-95",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-2.5 w-full cursor-pointer px-3 py-2 text-[13px] font-medium transition-all rounded-md ${variantClasses[variant]}`}
|
||||
className={`flex items-center gap-2.5 w-full cursor-pointer px-3 py-2 text-[13px] font-medium transition-all rounded-md ${variantClasses[variant]}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
|
||||
@@ -29,13 +29,13 @@ const CustomButton: React.FC<CustomButtonProps> = ({
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"bg-gradient-to-b from-[#1B9869] to-[#14704E] text-white shadow-sm hover:shadow-md hover:from-[#188A5F] hover:to-[#126446] focus:ring-[#1B9869] disabled:from-gray-300 disabled:to-gray-300 disabled:text-gray-500 disabled:shadow-none",
|
||||
"bg-gradient-to-b from-primary to-primary-dark text-white shadow-sm hover:shadow-md hover:from-primary-hover hover:to-primary-dark-hover focus:ring-primary disabled:from-gray-300 disabled:to-gray-300 disabled:text-gray-500 disabled:shadow-none",
|
||||
secondary:
|
||||
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 disabled:bg-gray-300 disabled:text-gray-500",
|
||||
outlined:
|
||||
"border-2 border-[#1B9869] text-[#1B9869] bg-transparent hover:bg-gray-50 focus:ring-[#1B9869] disabled:border-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||
text: "text-[#1B9869] bg-transparent hover:bg-gray-50 focus:ring-[#1B9869] disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||
link: "text-[#1B9869] bg-transparent hover:underline p-0 h-auto focus:ring-transparent disabled:text-gray-400",
|
||||
"border-2 border-primary text-primary bg-transparent hover:bg-gray-50 focus:ring-primary disabled:border-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||
text: "text-primary bg-transparent hover:bg-gray-50 focus:ring-primary disabled:text-gray-400 disabled:hover:bg-transparent",
|
||||
link: "text-primary bg-transparent hover:underline p-0 h-auto focus:ring-transparent disabled:text-gray-400",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
|
||||
@@ -30,10 +30,10 @@ const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
|
||||
transition-all duration-200
|
||||
flex items-center justify-center
|
||||
border-gray-300 bg-white
|
||||
peer-checked:bg-[#1B9869] peer-checked:border-[#1B9869]
|
||||
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
|
||||
peer-checked:[&_svg]:opacity-100
|
||||
peer-hover:border-[#1B9869]
|
||||
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
|
||||
peer-hover:border-primary
|
||||
peer-focus:ring-2 peer-focus:ring-primary/20
|
||||
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -29,8 +29,8 @@ const CustomDatePicker = forwardRef<HTMLInputElement, CustomDatePickerProps>(
|
||||
pl-10 pr-3 py-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
|
||||
placeholder:text-gray-300
|
||||
${error
|
||||
|
||||
@@ -31,8 +31,8 @@ const CustomDateTimePicker = forwardRef<
|
||||
pl-10 pr-3 py-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
|
||||
placeholder:text-gray-300
|
||||
${error
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
@@ -40,6 +41,7 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-1.5 text-sm h-[36px]",
|
||||
@@ -49,9 +51,11 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
@@ -67,7 +71,9 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const selectedOption = options.find((opt) => String(opt.value) === String(value));
|
||||
const selectedOption = value !== '' && value !== null && value !== undefined
|
||||
? options.find((opt) => String(opt.value) === String(value))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||
@@ -83,15 +89,15 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white text-gray-900
|
||||
bg-white
|
||||
border ${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${sizeClasses[size]}
|
||||
px-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
flex items-center
|
||||
${!disabled ? 'cursor-pointer hover:border-[#1B9869]' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-[#1B9869] ring-2 ring-[#1B9869]/20' : ''}
|
||||
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
@@ -103,11 +109,11 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1 truncate text-left">
|
||||
<div className="flex-1 truncate text-left text-[14px] font-medium tracking-[0.25px] leading-[15px]">
|
||||
{selectedOption ? (
|
||||
<span>{selectedOption.label}</span>
|
||||
<span style={{ color: '#6C766D' }}>{selectedOption.label}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
<span style={{ color: '#6C766D' }}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -120,41 +126,52 @@ const CustomDropdown = React.forwardRef<HTMLDivElement, CustomDropdownProps>(
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#14704E] font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#EEF9EF]"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: "ml-6"
|
||||
}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Phone, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month" | "datetime-local";
|
||||
|
||||
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
interface CustomInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
label?: string;
|
||||
type?: InputType;
|
||||
leftIcon?: React.ReactNode;
|
||||
@@ -53,7 +53,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
target.value = target.value.slice(0, maxLength);
|
||||
}
|
||||
}
|
||||
onInput?.(e);
|
||||
onInput?.(e as any);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -67,7 +67,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
|
||||
<div
|
||||
className={`relative w-full ${isPhone
|
||||
? "flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-[#1B9869] focus-within:ring-2 focus-within:ring-[#1B9869]/20 transition-all duration-200"
|
||||
? "flex overflow-hidden rounded-lg border border-gray-300 bg-white focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-all duration-200"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
@@ -102,8 +102,8 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
px-3 ${sizeClasses[size]}
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
|
||||
placeholder:text-gray-500
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
@@ -118,7 +118,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
{!isPhone && isPassword ? (
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-[#1B9869]"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-primary"
|
||||
>
|
||||
{showPassword ? <Eye size={20} /> : <EyeOff size={20} />}
|
||||
</span>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface CustomModalProps {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
headerExtra?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
|
||||
primaryAction?: {
|
||||
@@ -52,6 +53,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
headerExtra,
|
||||
footer,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
@@ -107,23 +109,27 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || icon || showCloseButton) && (
|
||||
<div className="relative border-b border-gray-100 px-6 py-5 flex-shrink-0 flex items-start gap-3">
|
||||
<div className="relative border-b border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 pt-5 pb-4 flex-shrink-0 flex items-start gap-3">
|
||||
{icon && (
|
||||
<div className="mt-1 flex-shrink-0 text-[#1B9869]">
|
||||
<div className="mt-1 flex-shrink-0 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 pr-8">
|
||||
{title && (
|
||||
<h2 className="text-[17px] font-bold text-[#111827]">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-0.5 text-[13px] font-medium text-slate-500">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 pr-8 flex flex-col gap-6">
|
||||
<div>
|
||||
{title && (
|
||||
<h2 className="text-[18px] font-semibold leading-[28px] tracking-normal text-[#0F172A]">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-0.5 text-[13px] font-normal leading-none tracking-normal text-[#0F172A]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
{showCloseButton && (
|
||||
@@ -149,7 +155,7 @@ const CustomModal: React.FC<CustomModalProps> = ({
|
||||
|
||||
{/* Footer */}
|
||||
{(footer || primaryAction || secondaryAction) && (
|
||||
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-gray-100 bg-white px-6 py-4 flex-shrink-0`}>
|
||||
<div className={`flex items-center ${footer ? 'justify-end' : 'justify-between'} gap-3 border-t border-[#F1F5F9] bg-[#F8FAFC]/50 px-6 py-4 flex-shrink-0`}>
|
||||
{footer}
|
||||
{!footer && (
|
||||
<>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, X, Check } from "lucide-react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CustomMultiSelectProps {
|
||||
@@ -13,184 +15,171 @@ interface CustomMultiSelectProps {
|
||||
onChange?: (value: (string | number)[]) => void;
|
||||
placeholder?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
label,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = "Select options...",
|
||||
leftIcon,
|
||||
disabled,
|
||||
required,
|
||||
className = "",
|
||||
error,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const CustomMultiSelect = React.forwardRef<HTMLDivElement, CustomMultiSelectProps>(
|
||||
(
|
||||
{
|
||||
label,
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
disabled,
|
||||
className = "",
|
||||
required,
|
||||
leftIcon,
|
||||
error,
|
||||
size = "md",
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
const sizeClasses = {
|
||||
sm: "py-1.5 text-sm min-h-[36px]",
|
||||
md: "py-2.5 text-sm min-h-[42px]",
|
||||
lg: "py-3 text-base min-h-[48px]",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (option: Option) => {
|
||||
if (option.disabled) return;
|
||||
const optionValueStr = String(option.value);
|
||||
const isSelected = value.some(v => String(v) === optionValueStr);
|
||||
let newValue = [...value];
|
||||
if (isSelected) {
|
||||
newValue = newValue.filter(v => String(v) !== optionValueStr);
|
||||
} else {
|
||||
newValue.push(option.value);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
const selectedOptions = options.filter(opt => value.some(v => String(v) === String(opt.value)));
|
||||
|
||||
const handleSelect = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter((v) => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
onChange?.(newValue);
|
||||
};
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={ref}>
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
const removeValue = (e: React.MouseEvent, optionValue: string | number) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter((opt) => value.includes(opt.value));
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-1.5" ref={containerRef}>
|
||||
{label && (
|
||||
<label className="text-sm font-semibold text-gray-900">
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white text-gray-900 text-sm
|
||||
border ${isOpen
|
||||
? "border-[#1B9869] ring-2 ring-[#1B9869]/20"
|
||||
: error ? "border-red-500" : "border-gray-300"
|
||||
}
|
||||
px-3 py-0 h-[46px]
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
cursor-pointer
|
||||
flex items-center gap-2
|
||||
overflow-hidden
|
||||
${disabled
|
||||
? "bg-gray-50 text-gray-500 cursor-not-allowed border-gray-300"
|
||||
: ""
|
||||
}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
{leftIcon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 overflow-hidden w-full h-full">
|
||||
{selectedOptions.length === 0 ? (
|
||||
<span className="text-gray-500/50 text-nowrap">{placeholder}</span>
|
||||
) : selectedOptions.length === 1 ? (
|
||||
<span
|
||||
key={selectedOptions[0].value}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-[#1B9869]/10 dark:bg-[#1B9869]/20 text-[#1B9869] dark:text-emerald-400 text-xs font-medium border border-[#1B9869]/20 dark:border-emerald-800 min-w-0"
|
||||
>
|
||||
<span className="truncate">{selectedOptions[0].label}</span>
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-[#1B9869] dark:hover:text-emerald-200 shrink-0"
|
||||
onClick={(e) => removeValue(e, selectedOptions[0].value)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded bg-[#1B9869]/10 dark:bg-[#1B9869]/20 text-[#1B9869] dark:text-emerald-400 text-xs font-medium border border-[#1B9869]/20 dark:border-emerald-800">
|
||||
{selectedOptions.length} Selected
|
||||
<div className="relative w-full" ref={dropdownRef}>
|
||||
<div
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
className={`
|
||||
w-full rounded-lg
|
||||
bg-white
|
||||
border ${error ? 'border-red-500' : 'border-gray-300'}
|
||||
${sizeClasses[size]}
|
||||
px-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
flex items-center flex-wrap gap-1
|
||||
${!disabled ? 'cursor-pointer hover:border-primary' : 'cursor-not-allowed bg-gray-50 text-gray-500'}
|
||||
${isOpen ? 'border-primary ring-2 ring-primary/20' : ''}
|
||||
${leftIcon ? "pl-10" : ""}
|
||||
pr-10
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{leftIcon && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
{leftIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-hidden flex flex-col py-1">
|
||||
<div className="px-2 py-1 border-b border-gray-300">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-full px-2 py-1 text-sm bg-transparent outline-none text-gray-900 placeholder:text-gray-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-48">
|
||||
{options
|
||||
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
No options available
|
||||
</div>
|
||||
<div className="flex-1 text-left text-[14px] font-medium tracking-[0.25px] leading-[15px] flex flex-wrap gap-1 items-center">
|
||||
{selectedOptions.length > 0 ? (
|
||||
selectedOptions.map(opt => (
|
||||
<span key={opt.value} className="bg-gray-100 text-[#6C766D] px-2 py-0.5 rounded text-xs border border-gray-200">
|
||||
{opt.label}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
options
|
||||
.filter(opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={`
|
||||
px-3 py-2 text-sm cursor-pointer flex items-center justify-between
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#14704E] font-medium"
|
||||
: "text-gray-900 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.label}
|
||||
{isSelected && <Check size={16} className="text-[#1B9869]" strokeWidth={2.5} />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<span style={{ color: '#6C766D' }}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none flex items-center">
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No options available</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.some(v => String(v) === String(option.value));
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(option);
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-4 min-h-[44px] text-[15px]
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#EAF7EE] text-[#1B9869] font-medium"
|
||||
: option.disabled
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-[#0F172B] hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CustomMultiSelect.displayName = "CustomMultiSelect";
|
||||
|
||||
export default CustomMultiSelect;
|
||||
|
||||
@@ -29,10 +29,10 @@ const CustomRadio = forwardRef<HTMLInputElement, CustomRadioProps>(
|
||||
transition-all duration-200
|
||||
flex items-center justify-center
|
||||
border-gray-300 bg-white
|
||||
peer-checked:border-[#1B9869] peer-checked:bg-[#1B9869]
|
||||
peer-checked:border-transparent peer-checked:bg-gradient-to-b peer-checked:from-[#1B9869] peer-checked:to-[#14704E]
|
||||
peer-checked:[&>div]:opacity-100
|
||||
peer-hover:border-[#1B9869]
|
||||
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
|
||||
peer-hover:border-primary
|
||||
peer-focus:ring-2 peer-focus:ring-primary/20
|
||||
peer-disabled:bg-gray-100 peer-disabled:border-gray-200
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { ChevronDown, X, Check } from "lucide-react";
|
||||
import DropdownPortal from "./DropdownPortal";
|
||||
|
||||
interface Option {
|
||||
label: string;
|
||||
@@ -43,6 +44,7 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "py-1 text-sm",
|
||||
@@ -66,9 +68,11 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
!dropdownRef.current.contains(target) &&
|
||||
!(panelRef.current && panelRef.current.contains(target))
|
||||
) {
|
||||
setIsOpen(false);
|
||||
// Revert to selected value label if closing without selection
|
||||
@@ -181,8 +185,8 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
${sizeClasses[size]}
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
|
||||
placeholder:text-gray-500
|
||||
${leftIcon ? "pl-10" : "px-3"}
|
||||
@@ -216,9 +220,16 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
</div>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && !disabled && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto flex flex-col">
|
||||
{filteredOptions.map((option, index) => {
|
||||
<DropdownPortal
|
||||
anchorRef={dropdownRef}
|
||||
isOpen={isOpen && !disabled}
|
||||
ref={panelRef}
|
||||
className="bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto flex flex-col"
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 text-center">No results found</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => {
|
||||
const isSelected = String(option.value) === String(value);
|
||||
return (
|
||||
<button
|
||||
@@ -227,28 +238,31 @@ const CustomSearchableDropdown = React.forwardRef<
|
||||
onClick={() => handleSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`
|
||||
w-full text-left px-4 py-2.5 text-[15px]
|
||||
w-full text-left px-3 min-h-[42px] text-[14px] leading-none
|
||||
transition-colors duration-150 flex items-center gap-2
|
||||
${isSelected
|
||||
? "bg-[#F0FDF4] text-[#14704E] font-medium"
|
||||
? "bg-[#EEF9EF]"
|
||||
: highlightedIndex === index
|
||||
? "bg-gray-50 text-gray-900"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && <Check size={16} className="text-[#1B9869] shrink-0" strokeWidth={2.5} />}
|
||||
<span className={isSelected ? "ml-1" : "ml-6"}>{option.label}</span>
|
||||
{isSelected && <Check size={16} className="text-primary shrink-0" strokeWidth={2.5} />}
|
||||
<span
|
||||
className={
|
||||
isSelected
|
||||
? "ml-1 font-medium bg-gradient-to-b from-[#1B9869] to-[#14704E] bg-clip-text text-transparent"
|
||||
: "ml-6"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && !disabled && filteredOptions.length === 0 && (
|
||||
<div className="absolute z-[9999] w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg p-4 text-center text-sm text-gray-500">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
})
|
||||
)}
|
||||
</DropdownPortal>
|
||||
</div>
|
||||
{props.error && <p className="text-xs text-red-500 mt-1">{props.error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -34,12 +34,12 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
|
||||
const finalVariant = variant || getVariantFromStatus(status);
|
||||
|
||||
const styles = {
|
||||
success: { bg: "bg-[#EBF7F2]", text: "text-[#1B9869]", dot: "bg-[#1B9869]" },
|
||||
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600" },
|
||||
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600" },
|
||||
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600" },
|
||||
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600" },
|
||||
brand: { bg: "bg-[#EBF7F2]", text: "text-[#1B9869]", dot: "bg-[#1B9869]" },
|
||||
success: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "" },
|
||||
error: { bg: "bg-rose-50", text: "text-rose-600", dot: "bg-rose-600", border: "" },
|
||||
warning: { bg: "bg-amber-50", text: "text-amber-600", dot: "bg-amber-600", border: "" },
|
||||
info: { bg: "bg-sky-50", text: "text-sky-600", dot: "bg-sky-600", border: "" },
|
||||
neutral: { bg: "bg-slate-100", text: "text-slate-600", dot: "bg-slate-600", border: "" },
|
||||
brand: { bg: "bg-[#E4FAE7]", text: "text-[#1B9869]", dot: "bg-[#1B9869]", border: "border border-[#1B9869]" },
|
||||
};
|
||||
|
||||
const currentStyle = styles[finalVariant];
|
||||
@@ -55,8 +55,8 @@ const CustomStatus: React.FC<CustomStatusProps> = ({
|
||||
aria-disabled={!isClickable}
|
||||
className={`
|
||||
inline-flex items-center justify-center gap-2 px-3 py-1.5 rounded-full
|
||||
text-[13px] font-semibold antialiased transition-all duration-300
|
||||
${currentStyle.bg} ${currentStyle.text}
|
||||
text-[11px] font-semibold leading-[16.5px] tracking-[0px] antialiased transition-all duration-300
|
||||
${currentStyle.bg} ${currentStyle.text} ${currentStyle.border}
|
||||
${isClickable ? "hover:brightness-95 active:scale-95 cursor-pointer" : "cursor-default pointer-events-none"}
|
||||
${className}
|
||||
`}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import CustomStatus from "./CustomStatus";
|
||||
import SuccessTick from "../../assets/icons/SuccessTick.png";
|
||||
|
||||
interface CustomSuccessModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
cohortName?: string;
|
||||
cohortStatus?: string;
|
||||
cohortDescription?: string;
|
||||
}
|
||||
|
||||
const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = "Success!",
|
||||
description = "Your action has been completed successfully.",
|
||||
buttonText = "Continue",
|
||||
title = "Cohort Created Successfully.",
|
||||
cohortName,
|
||||
cohortStatus,
|
||||
cohortDescription,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
@@ -33,7 +39,7 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
@@ -43,47 +49,59 @@ const CustomSuccessModal: React.FC<CustomSuccessModalProps> = ({
|
||||
|
||||
{/* Modal Content */}
|
||||
<div
|
||||
className="relative z-10 flex w-full max-w-sm flex-col items-center justify-center overflow-hidden rounded-3xl bg-white p-8 text-center shadow-2xl animate-in zoom-in-95 fade-in duration-300 sm:max-w-md"
|
||||
className="relative z-10 flex w-full max-w-lg flex-col items-center justify-center overflow-hidden rounded-xl bg-white p-8 text-center shadow-2xl animate-in zoom-in-95 fade-in duration-300"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
<X size={24} strokeWidth={2.5} className="text-[#152A3C]" />
|
||||
</button>
|
||||
|
||||
{/* Animated Icon Background */}
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-[#1B9869]/10">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#1B9869]/20 animate-pulse">
|
||||
<svg
|
||||
className="h-8 w-8 text-[#1B9869]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mb-6 mt-4 flex items-center justify-center">
|
||||
<img src={SuccessTick} alt="Success" className="h-16 w-16" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="mb-2 text-2xl font-bold text-gray-900">
|
||||
<h3 className="mb-6 text-[28px] font-extrabold text-[#152A3C]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mb-8 text-center text-gray-500 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-xl bg-[#1B9869] py-3.5 px-4 text-base font-semibold text-white shadow-lg transition-all hover:bg-[#14704E] hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0 focus:outline-none focus:ring-2 focus:ring-[#1B9869] focus:ring-offset-2"
|
||||
>
|
||||
{buttonText}
|
||||
</button>
|
||||
{cohortName && (
|
||||
<div className="w-full rounded-[12px] bg-[#F4F8FA] p-5 text-left border border-blue-50/50">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold tracking-wider text-[#1B9869] uppercase mb-1">
|
||||
COHORT NAME
|
||||
</p>
|
||||
<p className="text-base font-bold text-[#152A3C]">
|
||||
{cohortName}
|
||||
</p>
|
||||
</div>
|
||||
{cohortStatus && (
|
||||
<CustomStatus status={cohortStatus as any} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cohortDescription && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold tracking-wider text-[#1B9869] uppercase mb-1">
|
||||
DESCRIPTION
|
||||
</p>
|
||||
<p className="text-[13px] font-medium text-[#152A3C]">
|
||||
{cohortDescription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,12 +26,12 @@ const CustomSwitch = forwardRef<HTMLInputElement, CustomSwitchProps>(
|
||||
<div
|
||||
className={`
|
||||
w-11 h-6 bg-gray-200 rounded-full
|
||||
peer-focus:ring-2 peer-focus:ring-[#1B9869]/20
|
||||
peer-focus:ring-2 peer-focus:ring-primary/20
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all
|
||||
peer-checked:bg-[#1B9869]
|
||||
peer-checked:bg-primary
|
||||
peer-disabled:bg-gray-100
|
||||
`}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Search, ChevronLeft, ChevronRight, MoreVertical, Filter, ArrowDownUp } from "lucide-react";
|
||||
import { Search, ChevronLeft, ChevronRight, Filter, ArrowDownUp } from "lucide-react";
|
||||
import CustomInput from "./CustomInput";
|
||||
|
||||
export interface Column<T> {
|
||||
@@ -67,7 +67,7 @@ export function CustomTable<T>({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col bg-white rounded-[20px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="w-full flex flex-col bg-white rounded-[14px] shadow-sm border border-gray-100 overflow-hidden">
|
||||
|
||||
{/* Top Header Section */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
@@ -96,7 +96,7 @@ export function CustomTable<T>({
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/80 border-b border-gray-100">
|
||||
<tr className="bg-[#F3F6F5] border-b border-[#F9FAFB]">
|
||||
{columns.map((col, index) => (
|
||||
<th
|
||||
key={index}
|
||||
@@ -120,7 +120,7 @@ export function CustomTable<T>({
|
||||
className={`border-b border-gray-50 hover:bg-gray-50/50 transition-colors ${onRowClick ? "cursor-pointer" : ""}`}
|
||||
>
|
||||
{columns.map((col, colIndex) => (
|
||||
<td key={colIndex} className={`py-5 px-6 text-sm text-gray-700 ${col.className || ""}`}>
|
||||
<td key={colIndex} className={`py-5 px-6 ${col.className || ''}`}>
|
||||
{typeof col.accessor === "function"
|
||||
? col.accessor(row)
|
||||
: (row[col.accessor] as React.ReactNode)}
|
||||
@@ -140,7 +140,7 @@ export function CustomTable<T>({
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50/30 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between py-4 px-6 bg-[#F3F6F5] border-t border-[#E4E9F2]">
|
||||
<div className="text-[13px] font-medium text-gray-500">
|
||||
Showing {totalItems > 0 ? startIndex : 0} to {endIndex} of {totalItems} {itemName}
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ export function CustomTable<T>({
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
@@ -160,9 +160,9 @@ export function CustomTable<T>({
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg text-sm font-semibold transition-colors
|
||||
${currentPage === page
|
||||
? "bg-[#1B9869] text-white border border-[#1B9869]"
|
||||
: "bg-white text-gray-600 border border-gray-200 hover:bg-gray-50 hover:text-gray-900"
|
||||
${currentPage === page
|
||||
? "bg-gradient-to-b from-primary to-primary-dark text-white"
|
||||
: "bg-white text-[#9FACA1] border border-[#9FACA1] hover:bg-gray-50 hover:text-gray-900"
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -174,7 +174,7 @@ export function CustomTable<T>({
|
||||
<button
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg border border-[#9FACA1] bg-white text-[#9FACA1] hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
|
||||
@@ -59,8 +59,8 @@ const CustomTextArea = forwardRef<HTMLTextAreaElement, CustomTextAreaProps>(
|
||||
px-3 ${sizeClasses[size]}
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
|
||||
placeholder:text-gray-500
|
||||
resize-y
|
||||
|
||||
@@ -29,8 +29,8 @@ const CustomTimePicker = forwardRef<HTMLInputElement, CustomTimePickerProps>(
|
||||
pl-10 pr-3 py-3
|
||||
outline-none
|
||||
transition-all duration-200
|
||||
hover:border-[#1B9869]
|
||||
focus:border-[#1B9869] focus:ring-2 focus:ring-[#1B9869]/20
|
||||
hover:border-primary
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-200
|
||||
placeholder:text-gray-300
|
||||
${error
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const GAP = 8;
|
||||
const MAX_PANEL_HEIGHT = 240;
|
||||
const MIN_PANEL_HEIGHT = 100;
|
||||
|
||||
interface DropdownPortalProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
isOpen: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Portals the panel to document.body with fixed positioning so it isn't clipped by scrollable ancestors, and flips above the trigger when there's no room below.
|
||||
const DropdownPortal = React.forwardRef<HTMLDivElement, DropdownPortalProps>(
|
||||
({ anchorRef, isOpen, className = "", children }, forwardedRef) => {
|
||||
const [style, setStyle] = useState<React.CSSProperties | null>(null);
|
||||
|
||||
const recompute = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const openUp = spaceBelow < MAX_PANEL_HEIGHT && spaceAbove > spaceBelow;
|
||||
const clampHeight = (space: number) =>
|
||||
Math.min(MAX_PANEL_HEIGHT, Math.max(space - GAP * 2, MIN_PANEL_HEIGHT));
|
||||
|
||||
setStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
...(openUp
|
||||
? {
|
||||
bottom: window.innerHeight - rect.top + GAP,
|
||||
maxHeight: clampHeight(spaceAbove),
|
||||
}
|
||||
: {
|
||||
top: rect.bottom + GAP,
|
||||
maxHeight: clampHeight(spaceBelow),
|
||||
}),
|
||||
});
|
||||
}, [anchorRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setStyle(null);
|
||||
return;
|
||||
}
|
||||
recompute();
|
||||
window.addEventListener("scroll", recompute, true);
|
||||
window.addEventListener("resize", recompute);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", recompute, true);
|
||||
window.removeEventListener("resize", recompute);
|
||||
};
|
||||
}, [isOpen, recompute]);
|
||||
|
||||
if (!isOpen || !style) return null;
|
||||
|
||||
return createPortal(
|
||||
<div ref={forwardedRef} style={style} className={`z-[10050] ${className}`}>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DropdownPortal.displayName = "DropdownPortal";
|
||||
|
||||
export default DropdownPortal;
|
||||
@@ -22,6 +22,7 @@ import CustomStatus from "./CustomStatus";
|
||||
import CustomAlertBanner from "./CustomAlertBanner";
|
||||
import Skeleton from "./CustomSkeleton";
|
||||
import CustomTimePicker from "./CustomTimePicker";
|
||||
import CustomAccordionSection from "./CustomAccordionSection";
|
||||
|
||||
export {
|
||||
CustomInput,
|
||||
@@ -47,5 +48,6 @@ export {
|
||||
CustomStatus,
|
||||
CustomAlertBanner,
|
||||
Skeleton,
|
||||
CustomTimePicker
|
||||
CustomTimePicker,
|
||||
CustomAccordionSection
|
||||
};
|
||||
|
||||
+6
-2
@@ -1,8 +1,12 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--color-primary: #1B9869;
|
||||
--color-primary-dark: #14704E;
|
||||
--color-primary-hover: #188A5F;
|
||||
--color-primary-dark-hover: #126446;
|
||||
--font-sans: "Albert Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -19,7 +19,7 @@ const NAV_ITEMS = [
|
||||
{ label: 'Simulation Engine', path: '/simulation', icon: Settings2 },
|
||||
{ label: 'Recovery Incidents', path: '/recovery', icon: RefreshCcw, dot: true },
|
||||
{ label: 'Cohort Management', path: '/cohorts', icon: Users },
|
||||
{ label: 'Policy Engine', path: '/policy', icon: ShieldCheck },
|
||||
{ label: 'Policy Engine', path: '/policy-engine', icon: ShieldCheck },
|
||||
{ label: 'Configuration', path: '/config', icon: Settings },
|
||||
{ label: 'Audit Logs', path: '/audit', icon: History },
|
||||
];
|
||||
@@ -88,7 +88,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
}}
|
||||
className={`group flex items-center ${isCollapsed ? 'justify-center px-0 w-12 mx-auto' : 'gap-3 px-3.5'} py-[10px] rounded-[12px] text-[13px] transition-all duration-200 relative ${
|
||||
isActive
|
||||
? 'bg-gradient-to-b from-[#1B9869] to-[#14704E] text-white shadow-md shadow-[#1B9869]/20 font-semibold'
|
||||
? 'bg-gradient-to-b from-primary to-primary-dark text-white shadow-md shadow-primary/20 font-semibold'
|
||||
: 'text-[#475569] font-medium hover:bg-slate-200/40 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export default function AppSidebar({ isOpen, onClose }: AppSidebarProps) {
|
||||
)}
|
||||
|
||||
{item.dot && (
|
||||
<div className={`${isCollapsed ? 'absolute top-2 right-2' : 'ml-auto'} w-1.5 h-1.5 rounded-full ${isActive ? 'bg-white' : 'bg-[#1B9869]'}`} />
|
||||
<div className={`${isCollapsed ? 'absolute top-2 right-2' : 'ml-auto'} w-1.5 h-1.5 rounded-full ${isActive ? 'bg-white' : 'bg-primary'}`} />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
+40
-11
@@ -1,16 +1,45 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||
import babel from '@rolldown/plugin-babel'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
server: {
|
||||
port: 5174,
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Environment-specific configuration
|
||||
const envConfig = {
|
||||
localhost: {
|
||||
port: 5174,
|
||||
strictPort: false,
|
||||
},
|
||||
dev: {
|
||||
port: 9501,
|
||||
strictPort: false,
|
||||
},
|
||||
test: {
|
||||
port: 9502,
|
||||
strictPort: false,
|
||||
},
|
||||
uat: {
|
||||
port: 5176,
|
||||
strictPort: false,
|
||||
},
|
||||
}
|
||||
|
||||
const serverConfig = envConfig[mode as keyof typeof envConfig] || envConfig.localhost
|
||||
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
console.log(`\n======================================`)
|
||||
console.log(`🚀 Starting frontend in [${mode}] mode`)
|
||||
console.log(`🔗 Backend API URL: ${env.VITE_API_URL || 'NOT SET'}`)
|
||||
console.log(`======================================\n`)
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
server: serverConfig,
|
||||
define: {
|
||||
__APP_ENV__: JSON.stringify(mode),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user