34 Commits
Author SHA1 Message Date
Syed Waseem khadri Rafai 4c0616f1b0 Merge pull request 'feat: enhance AddPolicyEngine with logic dropdown and priority management; update PolicyEngineList for search functionality' (#14) from ameenah into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/14
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-15 07:45:40 +00:00
amee 741130732f feat: enhance AddPolicyEngine with logic dropdown and priority management; update PolicyEngineList for search functionality 2026-07-13 18:17:49 +05:30
Syed Waseem khadri Rafai 4b21a57372 Merge pull request 'Policy engine screens' (#13) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/13
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-13 05:50:42 +00:00
azeeee05 b518312696 Policy engine screens 2026-07-13 11:12:54 +05:30
Syed Waseem khadri Rafai 194070ea7f Merge pull request 'feat: enhance AddCohart component with new sections and improved navigation; UI fixes' (#12) from ameenah into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/12
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-11 06:56:02 +00:00
amee 80265bdf6b feat: enhance AddCohart component with new sections and improved navigation; UI fixes 2026-07-11 10:45:00 +05:30
Syed Waseem khadri Rafai 56aeda3f1a Merge pull request 'azeem' (#11) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/11
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-10 05:38:02 +00:00
azeeee05 5748885698 feat: implement AddCohart modal with multi-step form and master data integration 2026-07-10 10:53:54 +05:30
azeeee05 d3f5d494ca feat: implement AddCohort form with multi-select support and step-based navigation 2026-07-10 10:42:46 +05:30
Syed Waseem khadri Rafai fbbb82cde6 Merge pull request 'azeem' (#10) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/10
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-09 09:28:59 +00:00
azeeee05 9a9bcfd3cd refactor: rename local environment mode to localhost and add startup configuration logging 2026-07-09 14:38:59 +05:30
azeeee05 66f562c3a1 feat: implement CustomInput component and enhance environment/build script configuration 2026-07-09 14:21:12 +05:30
Syed Waseem khadri Rafai 75f98cecb3 Merge pull request 'waseem' (#9) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/9
2026-07-09 07:39:10 +00:00
waseem khadri 8f596bf476 Merge branch 'development' of https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend into waseem 2026-07-09 13:07:51 +05:30
waseem khadri 60ee5aaed4 fix: rename build script to maintain consistency with other build commands 2026-07-09 13:07:27 +05:30
Syed Waseem khadri Rafai 33a11828d5 Merge pull request 'waseem' (#8) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/8
2026-07-09 06:09:10 +00:00
waseem khadri 5cbf6baa3a feat: update environment setup and script commands for consistency 2026-07-09 11:37:57 +05:30
waseem khadri 447f0b567f Merge branch 'development' of https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend into waseem 2026-07-09 11:27:10 +05:30
waseem khadri cb99b4b704 feat: update API URLs in environment configuration files for development and testing 2026-07-09 11:26:51 +05:30
Syed Waseem khadri Rafai 8c43480fb8 Merge pull request 'feat: create AddCohart component with multi-step form for managing cohort creation and updates' (#7) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/7
2026-07-09 05:44:27 +00:00
azeeee05 9d4045881c feat: create AddCohart component with multi-step form for managing cohort creation and updates 2026-07-09 11:13:21 +05:30
Syed Waseem khadri Rafai 7014fa9e13 Merge pull request 'feat: add environment configuration files for development, testing, and UAT' (#6) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/6
2026-07-09 05:42:02 +00:00
waseem khadri 9c32d2a3a4 feat: add environment configuration files for development, testing, and UAT 2026-07-09 11:11:22 +05:30
Syed Waseem khadri Rafai 1a28b05cdc Merge pull request 'feat: enhance environment configuration with multiple setups and update documentation' (#5) from waseem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/5
2026-07-09 05:40:11 +00:00
waseem khadri b9d2e1fee6 feat: enhance environment configuration with multiple setups and update documentation 2026-07-09 11:08:54 +05:30
Syed Waseem khadri Rafai cdf4125a24 Merge pull request 'feat: implement cohort creation and editing modal with multi-step wizard and success feedback' (#4) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/4
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-09 05:13:45 +00:00
azeeee05 218670d336 feat: implement cohort creation and editing modal with multi-step wizard and success feedback 2026-07-09 10:40:10 +05:30
Syed Waseem khadri Rafai 41d1789bcf Merge pull request 'azeem' (#3) from azeem into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/3
2026-07-08 10:55:21 +00:00
azeeee05 fc1cfc447c feat: implement comprehensive library of reusable UI components and global theme configuration 2026-07-08 15:05:19 +05:30
azeeee05 edf801856a feat: implement cohort management module with creation and editing capabilities 2026-07-08 12:56:18 +05:30
Syed Waseem khadri Rafai cdbf1edf84 Merge pull request 'ameenah' (#2) from ameenah into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/2
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-08 06:29:30 +00:00
amee 2452aceaf0 feat: integrate AddCohart component for cohort creation in CohortList 2026-07-08 11:56:57 +05:30
amee 924aede2ca feat: enhance cohort management using custom components 2026-07-08 05:22:24 +05:30
Syed Waseem khadri Rafai 04a6607904 Merge pull request 'main' (#1) from main into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_frontend/pulls/1
Reviewed-by: Syed Waseem khadri Rafai <waseem.khadri@maskatech.com>
2026-07-07 06:01:20 +00:00
46 changed files with 2945 additions and 789 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_URL=http://localhost:3001/api
+4
View File
@@ -0,0 +1,4 @@
# Development Environment
VITE_API_URL=https://ardev-api.maskantech.in/api
VITE_ENV=dev
VITE_APP_NAME=AeroResolve (Dev)
+5
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
# Local Environment
VITE_API_URL=http://localhost:3001/api
VITE_ENV=local
VITE_APP_NAME=AeroResolve
+4
View File
@@ -0,0 +1,4 @@
# Test/QA Environment
VITE_API_URL=https://artest-api.maskantech.in/api
VITE_ENV=test
VITE_APP_NAME=AeroResolve (Test)
+4
View File
@@ -0,0 +1,4 @@
# UAT Environment
VITE_API_URL=https://api-uat.example.com/api
VITE_ENV=uat
VITE_APP_NAME=AeroResolve (UAT)
-1
View File
@@ -10,7 +10,6 @@ lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
+133
View File
@@ -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
+43 -4
View File
@@ -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
View File
@@ -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",
+4
View File
@@ -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>
)
+20 -26
View File
@@ -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}`);
}
+63 -9
View File
@@ -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;
}
+646 -249
View File
@@ -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}
/>
)}
</>
);
}
+2 -161
View File
@@ -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 />;
}
+88
View File
@@ -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,
};
}
+22
View File
@@ -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';
+14
View File
@@ -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;
+4 -4
View File
@@ -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?.();
+4 -4
View File
@@ -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 = {
+3 -3
View File
@@ -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
`}
>
+2 -2
View File
@@ -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
+59 -42
View File
@@ -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>
+6 -6
View File
@@ -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>
+20 -14
View File
@@ -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 && (
<>
+149 -160
View File
@@ -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;
+3 -3
View File
@@ -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>
+8 -8
View File
@@ -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}
`}
+54 -36
View File
@@ -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
);
};
+2 -2
View File
@@ -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
`}
/>
+10 -10
View File
@@ -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>
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+73
View File
@@ -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;
+3 -1
View File
@@ -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
View File
@@ -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 {
+3 -3
View File
@@ -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
View File
@@ -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),
},
}
})