first commit

This commit is contained in:
laxmanhalaki 2025-10-22 10:27:06 +05:30
commit 6fe42e8e5b
77 changed files with 17892 additions and 0 deletions

1729
App.tsx Normal file

File diff suppressed because it is too large Load Diff

3
Attributions.md Normal file
View File

@ -0,0 +1,3 @@
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).

248
CLAIM_MANAGEMENT_FLOW.md Normal file
View File

@ -0,0 +1,248 @@
# Claim Management System - Complete Data Flow
## 🎯 Overview
The claim management system now has complete integration with automatic dealer lookup and proper workflow management.
## 📊 Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. User Creates Claim │
│ (ClaimManagementWizard) │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. Select Dealer Code (e.g., RE-MH-001) │
│ │
│ Triggers: getDealerInfo(dealerCode) │
│ Returns from dealerDatabase.ts: │
│ • Dealer Name: "Royal Motors Mumbai" │
│ • Email: "dealer@royalmotorsmumbai.com" │
│ • Phone: "+91 98765 12345" │
│ • Address: "Shop No. 12-15, Central Avenue..." │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. Complete Form & Submit (formData) │
│ │
│ Captured Fields: │
│ • activityName │
│ • activityType │
│ • activityDate │
│ • location │
│ • dealerCode │
│ • dealerName ┐ │
│ • dealerEmail ├─ Auto-populated from database │
│ • dealerPhone │ │
│ • dealerAddress ┘ │
│ • estimatedBudget │
│ • requestDescription │
│ • periodStart, periodEnd │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. App.tsx Creates Request Object │
│ │
│ REQUEST_DATABASE[requestId] = { │
│ id: 'RE-REQ-2024-CM-XXX', │
│ title: '...', │
│ status: 'pending', │
│ currentStep: 1, │
│ totalSteps: 8, │
│ templateType: 'claim-management', │
│ claimDetails: { │
│ activityName: formData.activityName, │
│ dealerEmail: formData.dealerEmail, ← From DB │
│ dealerPhone: formData.dealerPhone, ← From DB │
│ dealerAddress: formData.dealerAddress, ← From DB │
│ estimatedBudget: formData.estimatedBudget, │
│ ...all other fields │
│ }, │
│ approvalFlow: [ 8 steps... ] │
│ } │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 5. MyRequests Shows Request in List │
│ │
│ RE-REQ-2024-CM-001 │
│ Dealer Marketing Activity Claim │
│ Status: Pending | Step 1 of 8 │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 6. User Clicks Request → RequestDetail.tsx │
│ │
│ Fetches from REQUEST_DATABASE[requestId] │
│ Displays all information: │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Overview Tab │ │
│ │ │ │
│ │ 📋 Activity Information │ │
│ │ • Activity Name: "..." │ │
│ │ • Activity Type: "..." │ │
│ │ • Date: "..." │ │
│ │ • Location: "..." │ │
│ │ │ │
│ │ 🏢 Dealer Information │ │
│ │ • Dealer Code: RE-MH-001 │ │
│ │ • Dealer Name: Royal Motors Mumbai │ │
│ │ • Email: dealer@royalmotorsmumbai.com ✓ │ │
│ │ • Phone: +91 98765 12345 ✓ │ │
│ │ • Address: Shop No. 12-15... ✓ │ │
│ │ │ │
│ │ 💰 Claim Request Details │ │
│ │ • Description: "..." │ │
│ │ • Estimated Budget: ₹2,45,000 ✓ │ │
│ │ • Period: Oct 1 - Oct 10 │ │
│ └─────────────────────────────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 7. Workflow Tab Shows 8-Step Process │
│ │
│ Step 1: Dealer Document Upload [PENDING] │
│ Action: [Upload Proposal Documents] ← Opens modal │
│ │
│ Step 2: Initiator Evaluation [WAITING] │
│ Actions: [Approve] [Request Modifications] │
│ │
│ Step 3: IO Confirmation (Auto) [WAITING] │
│ │
│ Step 4: Department Lead Approval [WAITING] │
│ Action: [Approve & Lock Budget] │
│ │
│ Step 5: Dealer Completion Documents [WAITING] │
│ Action: [Upload Completion Documents] │
│ │
│ Step 6: Initiator Verification [WAITING] │
│ Action: [Verify & Set Amount] ← Opens modal │
│ │
│ Step 7: E-Invoice Generation (Auto) [WAITING] │
│ │
│ Step 8: Credit Note Issuance [WAITING] │
│ Action: [Issue Credit Note] │
└─────────────────────────────────────────────────────────────────┘
```
## 🔄 Key Features Implemented
### 1. Dealer Database Auto-Population
```typescript
// When dealer selected in wizard
handleDealerChange('RE-MH-001')
getDealerInfo('RE-MH-001')
Returns complete dealer object
Auto-fills: name, email, phone, address
```
### 2. Complete Data Capture
- ✅ All activity details
- ✅ All dealer information (from database)
- ✅ Estimated budget
- ✅ Request description
- ✅ Period dates
### 3. Step-Specific Actions
Each workflow step shows relevant action buttons:
- **Upload buttons** for document steps
- **Approve/Reject buttons** for approval steps
- **Set Amount button** for verification step
- **Automatic processing** for system steps
### 4. Modal Integration
- **DealerDocumentModal**: For steps 1 & 5
- Upload multiple documents
- Add dealer comments
- Validation before submit
- **InitiatorVerificationModal**: For step 6
- Review completion documents
- Set final approved amount
- Add verification comments
## 🎨 UI Components
### ClaimManagementWizard
```
Step 1: Claim Details
├── Activity Name & Type
├── Dealer Selection → Auto-fills email, phone, address
├── Date & Location
├── Estimated Budget (new!)
└── Request Description
Step 2: Review & Submit
├── Activity Information Card
├── Dealer Information Card (with email, phone, address)
├── Date & Location Card (with budget)
└── Request Details Card
```
### RequestDetail Overview Tab
```
├── Activity Information
│ ├── Activity Name
│ ├── Activity Type
│ ├── Date
│ └── Location
├── Dealer Information
│ ├── Dealer Code
│ ├── Dealer Name
│ ├── Email ← From dealer database
│ ├── Phone ← From dealer database
│ └── Address ← From dealer database
└── Claim Request Details
├── Description
├── Estimated Budget ← User input
└── Period
```
## 📝 Database Schema
### dealerDatabase.ts Structure
```typescript
{
'RE-MH-001': {
code: 'RE-MH-001',
name: 'Royal Motors Mumbai',
email: 'dealer@royalmotorsmumbai.com',
phone: '+91 98765 12345',
address: 'Shop No. 12-15, Central Avenue, Andheri West',
city: 'Mumbai',
state: 'Maharashtra',
region: 'West',
managerName: 'Rahul Deshmukh'
},
// ... 9 more dealers
}
```
## ✅ Verification Checklist
- [x] Dealer database created with 10+ dealers
- [x] Auto-population works when dealer selected
- [x] All fields captured in claimDetails
- [x] RequestDetail displays all information
- [x] Step-specific action buttons appear
- [x] Modals integrate properly
- [x] 8-step workflow displays correctly
- [x] IDs synchronized across components
- [x] Data flows from wizard → app → detail
## 🚀 Ready for Testing!
The system is now complete and ready for end-to-end testing. All dealer information is automatically fetched from the database, properly saved in the request, and correctly displayed in the detail view.

337
CUSTOM_REQUEST_FIX.md Normal file
View File

@ -0,0 +1,337 @@
# Custom Request Details Page Fix
## Problem
Custom requests created through the NewRequestWizard were not displaying in the detail page. Instead, users only saw a "Go Back" button.
## Root Cause
1. **Database Lookup Issue**: Dynamic requests created through wizards were only stored in `App.tsx` component state (`dynamicRequests`), not in the static `CUSTOM_REQUEST_DATABASE`
2. **Component Props**: `RequestDetail` and `ClaimManagementDetail` components weren't receiving the `dynamicRequests` prop
3. **Data Flow Gap**: No connection between newly created requests and the detail view components
## Solution Implemented
### 1. Enhanced Request Creation (`App.tsx`)
Updated `handleNewRequestSubmit` to properly create custom request objects:
```typescript
const newCustomRequest = {
id: requestId,
title: requestData.title,
description: requestData.description,
category: requestData.category,
subcategory: requestData.subcategory,
status: 'pending',
priority: requestData.priority,
amount: requestData.budget,
template: 'custom',
initiator: { ... },
approvalFlow: [...], // Maps approvers from wizard
spectators: [...], // Maps spectators from wizard
documents: [],
auditTrail: [...],
// ... complete request object
};
setDynamicRequests([...dynamicRequests, newCustomRequest]);
```
**Features**:
- Generates unique request ID: `RE-REQ-2024-XXX`
- Maps wizard data to proper request structure
- Creates approval flow from selected approvers
- Adds spectators from wizard
- Initializes audit trail
- Sets proper SLA dates
- Navigates to My Requests page after creation
### 2. Updated Component Props
#### RequestDetail.tsx
```typescript
interface RequestDetailProps {
requestId: string;
onBack?: () => void;
onOpenModal?: (modal: string) => void;
dynamicRequests?: any[]; // NEW
}
```
#### ClaimManagementDetail.tsx
```typescript
interface ClaimManagementDetailProps {
requestId: string;
onBack?: () => void;
onOpenModal?: (modal: string) => void;
dynamicRequests?: any[]; // NEW
}
```
### 3. Enhanced Request Lookup Logic
Both detail components now check both static databases AND dynamic requests:
```typescript
const request = useMemo(() => {
// First check static database
const staticRequest = CUSTOM_REQUEST_DATABASE[requestId];
if (staticRequest) return staticRequest;
// Then check dynamic requests
const dynamicRequest = dynamicRequests.find((req: any) => req.id === requestId);
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestId, dynamicRequests]);
```
### 4. Intelligent Routing (`App.tsx`)
Updated `renderCurrentPage` for `request-detail` case:
```typescript
case 'request-detail':
// Check static databases
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[selectedRequestId];
const isCustomRequest = CUSTOM_REQUEST_DATABASE[selectedRequestId];
// Check dynamic requests
const dynamicRequest = dynamicRequests.find(...);
const isDynamicClaim = dynamicRequest?.templateType === 'claim-management';
const isDynamicCustom = dynamicRequest && !isDynamicClaim;
// Route to appropriate component with dynamicRequests prop
if (isClaimRequest || isDynamicClaim) {
return <ClaimManagementDetail {...} dynamicRequests={dynamicRequests} />;
} else {
return <RequestDetail {...} dynamicRequests={dynamicRequests} />;
}
```
### 5. Updated handleViewRequest
```typescript
const handleViewRequest = (requestId: string, requestTitle?: string) => {
setSelectedRequestId(requestId);
// Check all sources
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[requestId];
const isCustomRequest = CUSTOM_REQUEST_DATABASE[requestId];
const dynamicRequest = dynamicRequests.find(...);
const request = isClaimRequest || isCustomRequest || dynamicRequest;
setSelectedRequestTitle(requestTitle || request?.title || 'Unknown Request');
setCurrentPage('request-detail');
};
```
## Data Flow
### Creating a Custom Request
1. User clicks "Raise New Request" → "Custom Request"
2. Fills out NewRequestWizard form:
- Title
- Description
- Category/Subcategory
- Budget
- Priority
- Approvers (multiple)
- Spectators (multiple)
- Tagged Participants
3. On Submit:
- `handleNewRequestSubmit` creates complete request object
- Adds to `dynamicRequests` state
- Navigates to My Requests page
- Shows success toast
4. Viewing the Request:
- User clicks on request in My Requests
- `handleViewRequest` finds request in dynamicRequests
- Routes to `request-detail` page
- `RequestDetail` component receives `dynamicRequests` prop
- Component finds request in dynamicRequests array
- Displays complete request details
## Custom Request Details Page Features
### Header
- Back button
- Request ID with file icon
- Priority badge (urgent/standard)
- Status badge (pending/in-review/approved/rejected)
- Refresh button
- Title display
### SLA Progress Bar
- Color-coded (green/orange/red based on progress)
- Time remaining
- Progress percentage
- Due date
### Tabs
1. **Overview**
- Request Initiator (with avatar, role, email, phone)
- Request Details (description, category, subcategory, amount, dates)
- Quick Actions sidebar
- Spectators list
2. **Workflow**
- Step-by-step approval flow
- Color-coded status indicators
- TAT and elapsed time
- Comments from approvers
3. **Documents**
- List of uploaded documents
- Upload new document button
- View and download actions
4. **Activity**
- Complete audit trail
- Action icons
- User and timestamp for each action
### Quick Actions (Right Sidebar)
- Add Work Note (dark green button)
- Add Approver
- Add Spectator
- Modify SLA
- Approve Request (green)
- Reject Request (red)
## Testing Checklist
✅ **Request Creation**
- [ ] Create custom request through wizard
- [ ] Verify request appears in My Requests
- [ ] Check request ID is properly generated
- [ ] Verify all wizard data is captured
✅ **Request Detail Display**
- [ ] Click on custom request from My Requests
- [ ] Verify detail page loads (not "Go Back" button)
- [ ] Check all fields are populated correctly
- [ ] Verify initiator information displays
- [ ] Check description and category fields
✅ **Workflow Display**
- [ ] Verify approvers from wizard appear in workflow
- [ ] Check first approver is marked as "pending"
- [ ] Verify other approvers are "waiting"
- [ ] Check TAT hours are set
✅ **Spectators**
- [ ] Verify spectators from wizard appear
- [ ] Check avatar generation works
- [ ] Verify role display
✅ **Audit Trail**
- [ ] Check "Request Created" entry
- [ ] Check "Assigned to Approver" entry
- [ ] Verify timestamps are correct
✅ **Quick Actions**
- [ ] Test all quick action buttons
- [ ] Verify modals/toasts appear
- [ ] Check button styling
✅ **Claim Management Independence**
- [ ] Create claim request through ClaimManagementWizard
- [ ] Verify it routes to ClaimManagementDetail (purple theme)
- [ ] Verify custom requests route to RequestDetail (blue theme)
- [ ] Confirm no cross-contamination
## Sample Custom Request Data Structure
```typescript
{
id: 'RE-REQ-2024-004',
title: 'Marketing Campaign Budget Approval',
description: 'Q4 marketing campaign budget request...',
category: 'Marketing & Campaigns',
subcategory: 'Digital Marketing',
status: 'pending',
priority: 'express',
amount: '₹5,00,000',
slaProgress: 0,
slaRemaining: '5 days',
slaEndDate: 'Oct 20, 2024 5:00 PM',
currentStep: 1,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Current User',
role: 'Employee',
department: 'Marketing',
email: 'current.user@royalenfield.com',
phone: '+91 98765 43290',
avatar: 'CU'
},
approvalFlow: [
{
step: 1,
approver: 'Rajesh Kumar',
role: 'Marketing Director',
status: 'pending',
tatHours: 48,
elapsedHours: 0,
assignedAt: '2024-10-15T...',
comment: null,
timestamp: null
},
// ... more approvers
],
spectators: [
{
name: 'Finance Team',
role: 'Budget Monitoring',
avatar: 'FT'
}
],
documents: [],
auditTrail: [
{
type: 'created',
action: 'Request Created',
details: 'Custom request "..." created',
user: 'Current User',
timestamp: 'Oct 15, 2024 10:30 AM'
}
],
tags: ['custom-request']
}
```
## Future Enhancements
1. **Persistence**: Add backend API integration to persist dynamic requests
2. **Real-time Updates**: WebSocket for live status updates
3. **Document Upload**: Implement actual file upload functionality
4. **Notifications**: Email/push notifications for approvers
5. **Search**: Add search functionality in My Requests
6. **Filters**: Advanced filtering by status, priority, date
7. **Export**: Export request details to PDF
8. **Comments**: Thread-based commenting system
9. **Attachments**: Support for multiple file types
10. **Permissions**: Role-based access control
## Known Limitations
1. Dynamic requests are in-memory only (lost on refresh)
2. No actual file upload (UI only)
3. No real approval actions (mocked)
4. No email notifications
5. No database persistence
## Next Steps
1. Implement backend API for request persistence
2. Add authentication and authorization
3. Implement real approval workflows
4. Add document upload functionality
5. Create notification system
6. Add reporting and analytics
7. Mobile responsive improvements
8. Accessibility enhancements

188
ERROR_FIX.md Normal file
View File

@ -0,0 +1,188 @@
# Error Fix: "Objects are not valid as a React child"
## Problem
When creating a custom request through the NewRequestWizard, the application threw an error:
```
Error: Objects are not valid as a React child (found: object with keys {email, name, level, tat, tatType})
```
## Root Cause
The NewRequestWizard stores approvers, spectators, ccList, and invitedUsers as arrays of objects with the structure:
```typescript
{
email: string,
name: string,
level: number,
tat: number,
tatType: 'hours' | 'days'
}
```
When these objects were passed to `handleNewRequestSubmit` in App.tsx, they were being mapped to the request structure, but the mapping wasn't properly extracting the string values from the objects. Instead, entire objects were being assigned to fields that should contain strings.
## Solution
### 1. Enhanced Approver Mapping
Updated the `approvalFlow` mapping in `handleNewRequestSubmit` to properly extract values:
```typescript
approvalFlow: (requestData.approvers || [])
.filter((a: any) => a) // Filter out null/undefined
.map((approver: any, index: number) => {
// Extract name from email if name is not available
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
const approverEmail = approver?.email || '';
return {
step: index + 1,
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`, // STRING, not object
role: approver?.role || `Level ${approver?.level || index + 1} Approver`,
status: index === 0 ? 'pending' : 'waiting',
tatHours: approver?.tat ? (typeof approver.tat === 'string' ? parseInt(approver.tat) : approver.tat) : 48,
elapsedHours: index === 0 ? 0 : 0,
assignedAt: index === 0 ? new Date().toISOString() : null,
comment: null,
timestamp: null
};
})
```
**Key changes:**
- Added `.filter((a: any) => a)` to remove null/undefined entries
- Properly extract `approverName` from `name` or `email`
- Build a display string combining name and email
- Convert TAT to number (handles both string and number inputs)
### 2. Enhanced Spectator Mapping
Updated spectators mapping to properly extract values:
```typescript
spectators: (requestData.spectators || [])
.filter((s: any) => s && (s.name || s.email)) // Filter invalid entries
.map((spectator: any) => {
const name = spectator?.name || spectator?.email?.split('@')[0] || 'Observer';
return {
name: name, // STRING, not object
role: spectator?.role || spectator?.department || 'Observer',
avatar: name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'OB'
};
})
```
**Key changes:**
- Filter out entries without name or email
- Extract name from email if needed
- Safe avatar generation with fallback
### 3. Added Missing Fields
Added fields required by MyRequests component:
```typescript
currentApprover: requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email?.split('@')[0] || 'Pending Assignment',
approverLevel: `1 of ${requestData.approvers?.length || 1}`,
submittedDate: new Date().toISOString(),
estimatedCompletion: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
```
### 4. Fixed Audit Trail Message
Updated audit trail to safely extract approver name:
```typescript
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`
```
## Testing Checklist
✅ **Create Custom Request**
- [ ] Fill out NewRequestWizard with title, description
- [ ] Add 2-3 approvers with emails
- [ ] Add spectators (optional)
- [ ] Submit request
✅ **Verify No Errors**
- [ ] No console errors about "Objects are not valid as React child"
- [ ] Request appears in My Requests
- [ ] Can click on request
✅ **Verify Detail Page**
- [ ] Request detail page loads
- [ ] Approver names display correctly (not [object Object])
- [ ] Workflow tab shows all approvers
- [ ] Spectators display correctly (if added)
✅ **Verify My Requests Display**
- [ ] Request shows in list
- [ ] Current approver displays as string
- [ ] Approver level shows correctly (e.g., "1 of 3")
## Common Patterns to Avoid
### ❌ Bad: Rendering Objects Directly
```typescript
<span>{approver}</span> // If approver is {email: "...", name: "..."}
```
### ✅ Good: Extract String First
```typescript
<span>{approver.name || approver.email}</span>
```
### ❌ Bad: Assigning Object to String Field
```typescript
{
approver: approverObject // {email: "...", name: "..."}
}
```
### ✅ Good: Extract String Value
```typescript
{
approver: approverObject.name || approverObject.email
}
```
## Related Files Modified
1. **App.tsx** - `handleNewRequestSubmit` function
- Enhanced approver mapping
- Enhanced spectator mapping
- Added missing fields for MyRequests compatibility
- Fixed audit trail messages
## Data Flow
```
NewRequestWizard (formData)
├── approvers: [{email, name, level, tat, tatType}, ...]
├── spectators: [{email, name, role, department}, ...]
└── ...other fields
handleNewRequestSubmit (App.tsx)
├── Maps approvers → approvalFlow with STRING values
├── Maps spectators → spectators with STRING values
└── Creates complete request object
dynamicRequests state (App.tsx)
├── Stored in memory
└── Passed to components
RequestDetail / MyRequests
├── Receives proper data structure
└── Renders strings (no object errors)
```
## Prevention Tips
1. **Always validate data types** when mapping from wizard to database
2. **Extract primitive values** from objects before assigning to display fields
3. **Add TypeScript interfaces** to catch type mismatches early
4. **Test with console.log** before rendering to verify data structure
5. **Use optional chaining** (`?.`) to safely access nested properties
## Future Improvements
1. Add TypeScript interfaces for wizard form data
2. Add TypeScript interfaces for request objects
3. Create validation functions for data transformation
4. Add unit tests for data mapping functions
5. Create reusable mapping utilities

168
IMPLEMENTATION_COMPLETE.md Normal file
View File

@ -0,0 +1,168 @@
# Royal Enfield Claim Management System - Implementation Complete ✅
## Summary
The complete claim management workflow system has been successfully implemented with full integration of dealer database, step-specific actions, and proper data flow.
## What Was Implemented
### 1. Dealer Database System (`/utils/dealerDatabase.ts`)
- **Created comprehensive dealer database** with 10+ dealers across India
- Each dealer has complete information:
- Code, Name, Email, Phone
- Full Address (Street, City, State, Region)
- Manager Name
- **Utility functions**:
- `getDealerInfo(code)` - Fetch dealer by code
- `getAllDealers()` - Get all dealers
- `getDealersByRegion(region)` - Filter by region
- `searchDealers(term)` - Search functionality
- `formatDealerAddress(dealer)` - Format address for display
### 2. Updated ClaimManagementWizard
- **Auto-populates dealer information** when dealer is selected
- Captures all fields:
- Activity Name, Type, Date, Location
- Dealer Code, Name, Email, Phone, Address
- Estimated Budget (new field added)
- Request Description
- Period Start/End dates
- **Review step** now displays all dealer details including email, phone, and address
- Integrated with dealer database for automatic lookups
### 3. Updated RequestDetail Component
- **Fixed claim management detection** - Changed `claimData` to `claimDetails` throughout
- **Added step-specific action buttons** for all 8 workflow steps:
- **Step 1 & 5 (Dealer)**: Upload Documents modal
- **Step 2 (Initiator)**: Approve/Request Modifications buttons
- **Step 4 (Dept Lead)**: Approve & Lock Budget button
- **Step 6 (Initiator)**: Verify & Set Amount modal
- **Step 8 (Finance)**: Issue Credit Note button
- **Integrated modals**:
- DealerDocumentModal for document uploads
- InitiatorVerificationModal for amount verification
- **Display sections**:
- Activity Information (name, type, date, location)
- Dealer Information (code, name, email, phone, address)
- Claim Request Details (description, period, budget)
### 4. Updated App.tsx
- **Fixed duplicate amount field** in REQUEST_DATABASE
- **Updated claimDetails structure** to include all dealer fields:
- dealerEmail, dealerPhone, dealerAddress
- estimatedBudget
- **Proper data flow** from wizard → App.tsx → RequestDetail.tsx
### 5. Data Consistency
- **Synchronized IDs** across all components:
- Changed `RE-REQ-CM-001` to `RE-REQ-2024-CM-001`
- **Updated MyRequests** to show correct approver and step counts
- **Fixed REQUEST_DATABASE** in both App.tsx and RequestDetail.tsx to have matching data
## 8-Step Claim Management Workflow
1. **Dealer Document Upload** (Dealer) - Status: Pending
- Dealer uploads proposal, cost breakup, timeline
- Action: Upload Documents button → DealerDocumentModal
2. **Initiator Evaluation** (Initiator)
- Reviews dealer documents
- Action: Approve or Request Modifications buttons
3. **IO Confirmation** (System Auto-Process)
- Automatic IO generation
- No manual action required
4. **Department Lead Approval** (Department Lead)
- Approves and blocks budget in IO
- Action: Approve & Lock Budget button
5. **Dealer Completion Documents** (Dealer)
- Submits activity completion documents
- Action: Upload Documents button → DealerDocumentModal
6. **Initiator Verification** (Initiator)
- Verifies completion and sets final amount
- Action: Verify & Set Amount button → InitiatorVerificationModal
7. **E-Invoice Generation** (System Auto-Process)
- Auto-generates e-invoice
- No manual action required
8. **Credit Note Issuance** (Finance)
- Issues credit note to dealer
- Action: Issue Credit Note button
## Testing Checklist
✅ Dealer database successfully created with 10+ dealers
✅ Dealer information auto-populates when selected in wizard
✅ All dealer fields (email, phone, address) captured in claimDetails
✅ Estimated budget field added and captured
✅ Request IDs synchronized across all components
✅ RequestDetail displays all claim-specific fields correctly
✅ Step-specific action buttons appear for current pending step
✅ DealerDocumentModal and InitiatorVerificationModal integrated
✅ Workflow shows correct 8-step process with descriptions
✅ Data flows correctly: Wizard → App → Database → RequestDetail
## How to Test
1. **Create New Claim Request**:
- Dashboard → New Request → "Existing Template"
- Select "Claim Management"
- Fill in claim details
- Select a dealer (e.g., "RE-MH-001 • Royal Motors Mumbai")
- Notice email, phone auto-populate
- Add estimated budget
- Review all fields in step 2
- Submit
2. **View Claim Request**:
- Go to "My Requests"
- Click on claim request "RE-REQ-2024-CM-001"
- Verify "Overview" tab shows:
- Activity Information section
- Dealer Information section (with email, phone, address)
- Claim Request Details section
3. **Test Workflow Actions**:
- Go to "Workflow" tab
- Verify 8 steps display correctly
- Check that Step 1 (current pending) shows action button
- Click "Upload Proposal Documents" - opens DealerDocumentModal
- Test other step actions when implemented
## File Structure
```
├── /utils/dealerDatabase.ts (NEW - Dealer database)
├── /App.tsx (UPDATED - Dealer integration)
├── /components/
│ ├── ClaimManagementWizard.tsx (UPDATED - Dealer auto-fill)
│ ├── RequestDetail.tsx (UPDATED - Action buttons, display)
│ ├── MyRequests.tsx (UPDATED - Correct ID)
│ └── /modals/
│ ├── DealerDocumentModal.tsx (INTEGRATED)
│ └── InitiatorVerificationModal.tsx (INTEGRATED)
```
## Next Steps (Optional Enhancements)
1. **State Management**: Implement actual state updates when actions are performed
2. **Document Storage**: Implement actual file upload and storage
3. **Notifications**: Add email/SMS notifications for workflow transitions
4. **Reporting**: Add analytics dashboard for claim tracking
5. **Approval History**: Show detailed approval history with timestamps
6. **Budget Tracking**: Real-time budget allocation and tracking
## Conclusion
The Royal Enfield Claim Management system is now fully functional with:
- ✅ Complete dealer database with auto-population
- ✅ 8-step workflow with proper routing
- ✅ Step-specific action buttons and modals
- ✅ Comprehensive data capture and display
- ✅ Proper data flow and synchronization
The system is ready for testing and demonstration!

View File

@ -0,0 +1,217 @@
# Request Type Separation Implementation
## Overview
Successfully implemented complete separation between **Custom Requests** and **Claim Management Requests** to ensure independent processes, databases, and components.
## Architecture
### 1. Separate Databases
#### Custom Request Database
- **Location**: `/utils/customRequestDatabase.ts`
- **Purpose**: Stores all custom requests created via NewRequestWizard
- **Export**: `CUSTOM_REQUEST_DATABASE`
- **API Endpoints**: `CUSTOM_REQUEST_API_ENDPOINTS`
- **Features**:
- User-defined workflow
- Custom approvers added during creation
- Spectators and tagged participants
- Category/subcategory fields
- Flexible approval steps
#### Claim Management Database
- **Location**: `/utils/claimManagementDatabase.ts`
- **Purpose**: Stores all claim management requests created via ClaimManagementWizard
- **Export**: `CLAIM_MANAGEMENT_DATABASE`
- **API Endpoints**: `CLAIM_MANAGEMENT_API_ENDPOINTS`
- **Features**:
- Fixed 8-step workflow process
- Dealer information (code, name, contact, address)
- Activity details (name, type, location, date)
- Budget tracking
- Specialized modals (DealerDocumentModal, InitiatorVerificationModal)
### 2. Separate Detail Components
#### Request Detail Component
- **Location**: `/components/RequestDetail.tsx`
- **Purpose**: Display custom/standard requests only
- **Database**: Uses `CUSTOM_REQUEST_DATABASE`
- **Features**:
- Standard initiator information
- Category and subcategory display
- Approvers shown from request creation
- General description and specifications
- Flexible workflow steps
- Standard action buttons
#### Claim Management Detail Component
- **Location**: `/components/ClaimManagementDetail.tsx`
- **Purpose**: Display claim management requests only
- **Database**: Uses `CLAIM_MANAGEMENT_DATABASE`
- **Features**:
- Dealer information prominently displayed
- Activity information section
- 8-step workflow with specific actions per step
- Budget/amount tracking
- Claim-specific modals (dealer docs, verification)
- Purple theme for claim management branding
- Step-specific action buttons (Upload Documents, Verify Amount, etc.)
### 3. App.tsx Routing Logic
The main App component now includes intelligent routing:
```typescript
case 'request-detail':
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[selectedRequestId];
const isCustomRequest = CUSTOM_REQUEST_DATABASE[selectedRequestId];
if (isClaimRequest) {
return <ClaimManagementDetail ... />;
} else if (isCustomRequest) {
return <RequestDetail ... />;
}
```
### 4. API Endpoint Separation
Each database file includes its own API endpoint constants:
#### Custom Request Endpoints
- `/api/v1/custom-request/*`
- Includes: create, update, get, list, approve, reject, add approver/spectator/tagged, documents, work notes, etc.
#### Claim Management Endpoints
- `/api/v1/claim-management/*`
- Includes: create, update, get, list, dealer document upload, initiator evaluate, generate IO, department approval, completion docs, verify, e-invoice, credit note, etc.
## Benefits
### 1. Complete Independence
- Changes to claim management don't affect custom requests
- Changes to custom requests don't affect claim management
- Each process has its own data structure and business logic
### 2. Future-Proof
- Easy to add new template types (e.g., Budget Approval, Travel Requests)
- Each new template gets its own database and detail component
- No cross-contamination between processes
### 3. API Ready
- Separate endpoints allow different backend services
- Different authentication/authorization per process type
- Different data validation and business rules
### 4. Maintainability
- Clear separation of concerns
- Easy to debug issues (know which system to check)
- Independent testing for each process type
## Data Flow
### Custom Request Flow
1. User clicks "Custom Request" in NewRequestWizard
2. Fills out form with custom approvers, spectators, etc.
3. Submitted to `CUSTOM_REQUEST_DATABASE`
4. Clicking on request triggers `RequestDetail` component
5. All actions use `CUSTOM_REQUEST_API_ENDPOINTS`
### Claim Management Flow
1. User clicks "Existing Template" → "Claim Management"
2. Navigates to ClaimManagementWizard
3. Fills out claim-specific form (dealer, activity, budget)
4. Submitted to `CLAIM_MANAGEMENT_DATABASE`
5. Clicking on claim triggers `ClaimManagementDetail` component
6. All actions use `CLAIM_MANAGEMENT_API_ENDPOINTS`
## Database Schema Differences
### Custom Request
```typescript
{
id: string
title: string
description: string
category: string
subcategory: string
status: string
priority: string
template: 'custom'
approvalFlow: [...] // User-defined
// No claimDetails
}
```
### Claim Management Request
```typescript
{
id: string
title: string
template: 'claim-management'
claimDetails: {
activityName: string
activityType: string
location: string
dealerCode: string
dealerName: string
dealerEmail: string
dealerPhone: string
dealerAddress: string
estimatedBudget: string
// ... more claim-specific fields
}
approvalFlow: [...] // Fixed 8-step process
}
```
## Visual Differences
### Request Detail (Custom)
- Blue theme
- Standard file icon
- Category/Subcategory displayed
- "Request Detail" terminology
- Generic approval buttons
### Claim Management Detail (Purple)
- Purple theme throughout
- Receipt/claim icon
- "Claim Management" badge
- Dealer and Activity sections
- "Claim Amount" instead of "Total Amount"
- Step-specific buttons (Upload Documents, Verify Amount, etc.)
- "Claim Activity Timeline" instead of "Activity Timeline"
## Migration Notes
### Legacy Database
- Old `REQUEST_DATABASE` in App.tsx is now marked as `LEGACY_REQUEST_DATABASE`
- Combined export `REQUEST_DATABASE` = custom + claim for backward compatibility
- Will be removed in future once all components updated
### Components to Update (Future)
- Dashboard.tsx - update to use both databases
- RequestsList.tsx - update to use both databases
- MyRequests.tsx - update to use both databases
## Testing Checklist
- [ ] Custom request creation works
- [ ] Custom request detail page displays correctly
- [ ] Custom request actions (approve/reject) work
- [ ] Claim management creation works
- [ ] Claim management detail page displays correctly
- [ ] Claim-specific actions work (dealer upload, verification)
- [ ] No cross-contamination between types
- [ ] Routing correctly identifies request type
- [ ] Proper error handling for missing requests
## Future Enhancements
1. Add more template types (Budget Approval, Travel Request, etc.)
2. Create utility functions for database operations
3. Add TypeScript interfaces for better type safety
4. Implement actual API integration
5. Add request type indicators in lists
6. Create admin panel for template management

View File

@ -0,0 +1,795 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { toast } from 'sonner@2.0.3';
import { DealerDocumentModal } from './modals/DealerDocumentModal';
import { InitiatorVerificationModal } from './modals/InitiatorVerificationModal';
import { Progress } from './ui/progress';
import { Avatar, AvatarFallback } from './ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { CLAIM_MANAGEMENT_DATABASE } from '../utils/claimManagementDatabase';
import {
ArrowLeft,
Clock,
FileText,
MessageSquare,
CheckCircle,
XCircle,
Download,
Eye,
Flame,
Target,
TrendingUp,
RefreshCw,
Activity,
MapPin,
Mail,
Phone,
Building,
Receipt,
Upload,
UserPlus,
ClipboardList,
DollarSign,
Calendar
} from 'lucide-react';
interface ClaimManagementDetailProps {
requestId: string;
onBack?: () => void;
onOpenModal?: (modal: string) => void;
dynamicRequests?: any[];
}
// Utility functions
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
case 'urgent':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: Flame
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Target
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Target
};
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
icon: Clock
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Eye
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: XCircle
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Clock
};
}
};
const getSLAConfig = (progress: number) => {
if (progress >= 80) {
return {
bg: 'bg-red-50',
color: 'bg-red-500',
textColor: 'text-red-700'
};
} else if (progress >= 60) {
return {
bg: 'bg-orange-50',
color: 'bg-orange-500',
textColor: 'text-orange-700'
};
} else {
return {
bg: 'bg-green-50',
color: 'bg-green-500',
textColor: 'text-green-700'
};
}
};
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'pending':
case 'in-review':
return <Clock className="w-5 h-5 text-blue-600" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
const getActionTypeIcon = (type: string) => {
switch (type) {
case 'approval':
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejection':
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'comment':
return <MessageSquare className="w-5 h-5 text-blue-600" />;
case 'status_change':
return <RefreshCw className="w-5 h-5 text-orange-600" />;
case 'assignment':
return <UserPlus className="w-5 h-5 text-purple-600" />;
case 'created':
return <FileText className="w-5 h-5 text-blue-600" />;
default:
return <Activity className="w-5 h-5 text-gray-600" />;
}
};
export function ClaimManagementDetail({
requestId,
onBack,
onOpenModal,
dynamicRequests = []
}: ClaimManagementDetailProps) {
const [activeTab, setActiveTab] = useState('overview');
const [dealerDocModal, setDealerDocModal] = useState(false);
const [initiatorVerificationModal, setInitiatorVerificationModal] = useState(false);
// Get claim from database or dynamic requests
const claim = useMemo(() => {
// First check static database
const staticClaim = CLAIM_MANAGEMENT_DATABASE[requestId];
if (staticClaim) return staticClaim;
// Then check dynamic requests
const dynamicClaim = dynamicRequests.find((req: any) => req.id === requestId);
if (dynamicClaim) return dynamicClaim;
return null;
}, [requestId, dynamicRequests]);
if (!claim) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Claim Not Found</h2>
<p className="text-gray-600 mb-4">The claim request you're looking for doesn't exist.</p>
<Button onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Go Back
</Button>
</div>
</div>
);
}
const priorityConfig = getPriorityConfig(claim.priority);
const statusConfig = getStatusConfig(claim.status);
const slaConfig = getSLAConfig(claim.slaProgress);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header Section */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-4">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="mt-1"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center">
<Receipt className="w-6 h-6 text-purple-600" />
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl font-bold text-gray-900">{claim.id}</h1>
<Badge className={`${priorityConfig.color}`} variant="outline">
{claim.priority} priority
</Badge>
<Badge className={`${statusConfig.color}`} variant="outline">
<statusConfig.icon className="w-3 h-3 mr-1" />
{claim.status}
</Badge>
<Badge className="bg-purple-100 text-purple-800 border-purple-200" variant="outline">
<Receipt className="w-3 h-3 mr-1" />
Claim Management
</Badge>
</div>
<h2 className="text-lg font-semibold text-gray-900">{claim.title}</h2>
</div>
</div>
</div>
<div className="flex items-center gap-4">
{claim.amount && claim.amount !== 'TBD' && (
<div className="text-right">
<p className="text-sm text-gray-500">Claim Amount</p>
<p className="text-xl font-bold text-gray-900">{claim.amount}</p>
</div>
)}
<Button variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* SLA Progress */}
<div className={`${slaConfig.bg} rounded-lg border p-4 mt-4`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${slaConfig.textColor}`} />
<span className="text-sm font-medium text-gray-900">SLA Progress</span>
</div>
<span className={`text-sm font-semibold ${slaConfig.textColor}`}>
{claim.slaRemaining}
</span>
</div>
<Progress value={claim.slaProgress} className="h-2 mb-2" />
<p className="text-xs text-gray-600">
Due: {claim.slaEndDate} {claim.slaProgress}% elapsed
</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-white border border-gray-200 shadow-sm mb-6">
<TabsTrigger value="overview" className="gap-2">
<ClipboardList className="w-4 h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="workflow" className="gap-2">
<TrendingUp className="w-4 h-4" />
Workflow (8-Steps)
</TabsTrigger>
<TabsTrigger value="documents" className="gap-2">
<FileText className="w-4 h-4" />
Documents
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="w-4 h-4" />
Activity
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
{/* Activity Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityName || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Type</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityType || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Location</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
{claim.claimDetails?.location || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Date</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.activityDate || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Estimated Budget</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
{claim.claimDetails?.estimatedBudget || 'N/A'}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Period</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{claim.claimDetails?.periodStart || 'N/A'} - {claim.claimDetails?.periodEnd || 'N/A'}
</p>
</div>
</div>
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Description</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{claim.claimDetails?.requestDescription || claim.description || 'N/A'}
</p>
</div>
</CardContent>
</Card>
{/* Dealer Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Building className="w-5 h-5 text-purple-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Code</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.dealerCode || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{claim.claimDetails?.dealerName || 'N/A'}</p>
</div>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Contact Information</label>
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4 text-gray-400" />
<span>{claim.claimDetails?.dealerEmail || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4 text-gray-400" />
<span>{claim.claimDetails?.dealerPhone || 'N/A'}</span>
</div>
{claim.claimDetails?.dealerAddress && (
<div className="flex items-start gap-2 text-sm text-gray-700">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
<span>{claim.claimDetails.dealerAddress}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Initiator Information */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Request Initiator</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
{claim.initiator?.avatar || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{claim.initiator?.name || 'N/A'}</h3>
<p className="text-sm text-gray-600">{claim.initiator?.role || 'N/A'}</p>
<p className="text-sm text-gray-500">{claim.initiator?.department || 'N/A'}</p>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{claim.initiator?.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{claim.initiator?.phone || 'N/A'}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Quick Actions Sidebar (1/3 width) */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300"
onClick={() => onOpenModal?.('work-note')}
>
<MessageSquare className="w-4 h-4" />
Add Work Note
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300"
onClick={() => onOpenModal?.('add-spectator')}
>
<Eye className="w-4 h-4" />
Add Spectator
</Button>
<div className="pt-4 space-y-2">
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Step
</Button>
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Step
</Button>
</div>
</CardContent>
</Card>
{/* Spectators */}
{claim.spectators && claim.spectators.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Spectators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{claim.spectators.map((spectator: any, index: number) => (
<div key={index} className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{spectator.name}</p>
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Workflow Tab - 8 Step Process */}
<TabsContent value="workflow">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {claim.currentStep} of {claim.totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
{claim.approvalFlow && claim.approvalFlow.length > 0 ? (
<div className="space-y-4">
{claim.approvalFlow.map((step: any, index: number) => {
const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected';
return (
<div
key={index}
className={`relative p-5 rounded-lg border-2 transition-all ${
isActive
? 'border-purple-500 bg-purple-50 shadow-md'
: isCompleted
? 'border-green-500 bg-green-50'
: isRejected
? 'border-red-500 bg-red-50'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${
isActive ? 'bg-purple-100' :
isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' :
'bg-gray-100'
}`}>
{getStepIcon(step.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">Step {step.step}: {step.role}</h4>
<Badge variant="outline" className={
isActive ? 'bg-purple-100 text-purple-800 border-purple-200' :
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
'bg-gray-100 text-gray-800 border-gray-200'
}>
{step.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{step.approver}</p>
{step.description && (
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
)}
</div>
<div className="text-right">
{step.tatHours && (
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
)}
{step.elapsedHours !== undefined && step.elapsedHours > 0 && (
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
)}
</div>
</div>
{step.comment && (
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{step.comment}</p>
</div>
)}
{step.timestamp && (
<p className="text-xs text-gray-500 mt-2">
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {step.timestamp}
</p>
)}
{/* Workflow-specific Action Buttons */}
{isActive && (
<div className="mt-4 flex gap-2">
{step.step === 1 && step.role === 'Dealer - Document Upload' && (
<Button
size="sm"
onClick={() => setDealerDocModal(true)}
className="bg-purple-600 hover:bg-purple-700"
>
<Upload className="w-4 h-4 mr-2" />
Upload Documents
</Button>
)}
{step.step === 2 && step.role === 'Initiator Evaluation' && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve & Continue
</Button>
)}
{step.step === 4 && step.role === 'Department Lead Approval' && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve & Lock Budget
</Button>
)}
{step.step === 5 && step.role === 'Dealer - Completion Documents' && (
<Button
size="sm"
onClick={() => setDealerDocModal(true)}
className="bg-purple-600 hover:bg-purple-700"
>
<Upload className="w-4 h-4 mr-2" />
Upload Completion Docs
</Button>
)}
{step.step === 6 && step.role === 'Initiator Verification' && (
<Button
size="sm"
onClick={() => setInitiatorVerificationModal(true)}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Verify & Set Amount
</Button>
)}
{step.step === 8 && step.role.includes('Credit Note') && (
<Button
size="sm"
onClick={() => onOpenModal?.('approve')}
className="bg-green-600 hover:bg-green-700"
>
<CheckCircle className="w-4 h-4 mr-2" />
Issue Credit Note
</Button>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No workflow steps defined</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-purple-600" />
Claim Documents
</CardTitle>
<Button size="sm">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
</CardHeader>
<CardContent>
{claim.documents && claim.documents.length > 0 ? (
<div className="space-y-3">
{claim.documents.map((doc: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<FileText className="w-5 h-5 text-purple-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500">
{doc.size} Uploaded by {doc.uploadedBy} on {doc.uploadedAt}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm">
<Download className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No documents uploaded yet</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Activity Tab - Audit Trail */}
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-orange-600" />
Claim Activity Timeline
</CardTitle>
<CardDescription>
Complete audit trail of all claim management activities
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{claim.auditTrail && claim.auditTrail.length > 0 ? claim.auditTrail.map((entry: any, index: number) => (
<div key={index} className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 transition-colors border border-gray-100">
<div className="mt-1">
{getActionTypeIcon(entry.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-gray-900">{entry.action}</p>
<p className="text-sm text-gray-600 mt-1">{entry.details}</p>
<p className="text-xs text-gray-500 mt-1">by {entry.user}</p>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">{entry.timestamp}</span>
</div>
</div>
</div>
)) : (
<div className="text-center py-12">
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-sm text-gray-500">No activity recorded yet</p>
<p className="text-xs text-gray-400 mt-2">Actions and updates will appear here</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* Claim Management Modals */}
{dealerDocModal && (
<DealerDocumentModal
isOpen={dealerDocModal}
onClose={() => setDealerDocModal(false)}
onSubmit={async (documents) => {
console.log('Dealer documents submitted:', documents);
toast.success('Documents Uploaded', {
description: 'Your documents have been submitted for review.',
});
setDealerDocModal(false);
}}
dealerName={claim.claimDetails?.dealerName || 'Dealer'}
activityName={claim.claimDetails?.activityName || claim.title}
/>
)}
{initiatorVerificationModal && (
<InitiatorVerificationModal
isOpen={initiatorVerificationModal}
onClose={() => setInitiatorVerificationModal(false)}
onSubmit={async (data) => {
console.log('Verification data:', data);
toast.success('Verification Complete', {
description: `Amount set to ${data.approvedAmount}. E-invoice will be generated.`,
});
setInitiatorVerificationModal(false);
}}
activityName={claim.claimDetails?.activityName || claim.title}
requestedAmount={claim.claimDetails?.estimatedBudget || claim.amount || 'TBD'}
documents={claim.documents || []}
/>
)}
</div>
);
}

View File

@ -0,0 +1,651 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Badge } from './ui/badge';
import { Progress } from './ui/progress';
import { Calendar } from './ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { motion, AnimatePresence } from 'motion/react';
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Check,
Receipt,
Building,
MapPin,
Clock,
CheckCircle,
Info,
FileText,
DollarSign
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner@2.0.3';
import { getAllDealers, getDealerInfo, formatDealerAddress, type DealerInfo } from '../utils/dealerDatabase';
interface ClaimManagementWizardProps {
onBack?: () => void;
onSubmit?: (claimData: any) => void;
}
const CLAIM_TYPES = [
'Marketing Activity',
'Promotional Event',
'Dealer Training',
'Infrastructure Development',
'Customer Experience Initiative',
'Service Campaign'
];
// Fetch dealers from database
const DEALERS = getAllDealers();
const STEP_NAMES = [
'Claim Details',
'Review & Submit'
];
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
activityName: '',
activityType: '',
dealerCode: '',
dealerName: '',
dealerEmail: '',
dealerPhone: '',
dealerAddress: '',
activityDate: undefined as Date | undefined,
location: '',
requestDescription: '',
periodStartDate: undefined as Date | undefined,
periodEndDate: undefined as Date | undefined,
estimatedBudget: ''
});
const totalSteps = STEP_NAMES.length;
const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const isStepValid = () => {
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
case 2:
return true;
default:
return false;
}
};
const nextStep = () => {
if (currentStep < totalSteps && isStepValid()) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleDealerChange = (dealerCode: string) => {
const dealer = getDealerInfo(dealerCode);
if (dealer) {
updateFormData('dealerCode', dealer.code);
updateFormData('dealerName', dealer.name);
updateFormData('dealerEmail', dealer.email);
updateFormData('dealerPhone', dealer.phone);
updateFormData('dealerAddress', formatDealerAddress(dealer));
}
};
const handleSubmit = () => {
const claimData = {
...formData,
templateType: 'claim-management',
submittedAt: new Date().toISOString(),
status: 'pending',
currentStep: 'initiator-review',
workflowSteps: [
{
step: 1,
name: 'Initiator Evaluation',
status: 'pending',
approver: 'Current User (Initiator)',
description: 'Review and confirm all claim details and documents'
},
{
step: 2,
name: 'IO Confirmation',
status: 'waiting',
approver: 'System',
description: 'Automatic IO generation upon initiator approval'
},
{
step: 3,
name: 'Department Lead Approval',
status: 'waiting',
approver: 'Department Lead',
description: 'Budget blocking and final approval'
},
{
step: 4,
name: 'Document Submission',
status: 'waiting',
approver: 'Dealer',
description: 'Dealer submits completion documents'
},
{
step: 5,
name: 'Document Verification',
status: 'waiting',
approver: 'Initiator',
description: 'Verify completion documents'
},
{
step: 6,
name: 'E-Invoice Generation',
status: 'waiting',
approver: 'System',
description: 'Auto-generate e-invoice based on approved amount'
},
{
step: 7,
name: 'Credit Note Issuance',
status: 'waiting',
approver: 'Finance',
description: 'Issue credit note to dealer'
}
]
};
toast.success('Claim Request Created', {
description: 'Your claim management request has been submitted successfully.'
});
if (onSubmit) {
onSubmit(claimData);
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Receipt className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Claim Details</h2>
<p className="text-gray-600">
Provide comprehensive information about your claim request
</p>
</div>
<div className="max-w-3xl mx-auto space-y-6">
{/* Activity Name and Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label htmlFor="activityName" className="text-base font-semibold">Activity Name *</Label>
<Input
id="activityName"
placeholder="e.g., Himalayan Adventure Fest 2024"
value={formData.activityName}
onChange={(e) => updateFormData('activityName', e.target.value)}
className="mt-2 h-12"
/>
</div>
<div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
<SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select activity type" />
</SelectTrigger>
<SelectContent>
{CLAIM_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Dealer Selection */}
<div>
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
<SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select dealer">
{formData.dealerCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span>
<span className="text-gray-400"></span>
<span>{formData.dealerName}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{DEALERS.map((dealer) => (
<SelectItem key={dealer.code} value={dealer.code}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
<span className="text-gray-400"></span>
<span>{dealer.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{formData.dealerCode && (
<p className="text-sm text-gray-600 mt-2">
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
</p>
)}
</div>
{/* Date and Location */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-base font-semibold">Date *</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.activityDate}
onSelect={(date) => updateFormData('activityDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label htmlFor="location" className="text-base font-semibold">Location *</Label>
<Input
id="location"
placeholder="e.g., Mumbai, Maharashtra"
value={formData.location}
onChange={(e) => updateFormData('location', e.target.value)}
className="mt-2 h-12"
/>
</div>
</div>
{/* Request Detail */}
<div>
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
<Textarea
id="requestDescription"
placeholder="Provide a detailed description of your claim requirement..."
value={formData.requestDescription}
onChange={(e) => updateFormData('requestDescription', e.target.value)}
className="mt-2 min-h-[120px]"
/>
<p className="text-xs text-gray-500 mt-1">
Include key details about the claim, objectives, and expected outcomes
</p>
</div>
{/* Period (Optional) */}
<div>
<div className="flex items-center gap-2 mb-3">
<Label className="text-base font-semibold">Period (If Any)</Label>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label className="text-sm text-gray-600">Start Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.periodStartDate}
onSelect={(date) => updateFormData('periodStartDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-sm text-gray-600">End Date</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left mt-2 h-12"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={formData.periodEndDate}
onSelect={(date) => updateFormData('periodEndDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{(formData.periodStartDate || formData.periodEndDate) && (
<p className="text-xs text-gray-600 mt-2">
{formData.periodStartDate && formData.periodEndDate
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
: 'Please select both start and end dates for the period'}
</p>
)}
</div>
</div>
</motion.div>
);
case 2:
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
<p className="text-gray-600">
Review your claim details before submission
</p>
</div>
<div className="max-w-3xl mx-auto space-y-6">
{/* Activity Information */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-blue-50 to-indigo-50">
<CardTitle className="flex items-center gap-2">
<Receipt className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Name</Label>
<p className="font-semibold text-gray-900 mt-1">{formData.activityName}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Activity Type</Label>
<Badge variant="secondary" className="mt-1">{formData.activityType}</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Dealer Information */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-green-50 to-emerald-50">
<CardTitle className="flex items-center gap-2">
<Building className="w-5 h-5 text-green-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Code</Label>
<p className="font-semibold text-gray-900 mt-1 font-mono">{formData.dealerCode}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Dealer Name</Label>
<p className="font-semibold text-gray-900 mt-1">{formData.dealerName}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Email</Label>
<p className="text-gray-900 mt-1">{formData.dealerEmail}</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Phone</Label>
<p className="text-gray-900 mt-1">{formData.dealerPhone}</p>
</div>
{formData.dealerAddress && (
<div className="col-span-2">
<Label className="text-xs text-gray-600 uppercase tracking-wider">Address</Label>
<p className="text-gray-900 mt-1">{formData.dealerAddress}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Date & Location */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-purple-600" />
Date & Location
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'N/A'}
</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Location</Label>
<div className="flex items-center gap-2 mt-1">
<MapPin className="w-4 h-4 text-gray-500" />
<p className="font-semibold text-gray-900">{formData.location}</p>
</div>
</div>
{formData.estimatedBudget && (
<div className="col-span-2">
<Label className="text-xs text-gray-600 uppercase tracking-wider">Estimated Budget</Label>
<p className="text-xl font-bold text-blue-900 mt-1">{formData.estimatedBudget}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Request Details */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-orange-50 to-amber-50">
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-orange-600" />
Request Details
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
<p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
</div>
</div>
</CardContent>
</Card>
{/* Period (if provided) */}
{(formData.periodStartDate || formData.periodEndDate) && (
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-cyan-50 to-blue-50">
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5 text-cyan-600" />
Period
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Start Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Not specified'}
</p>
</div>
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">End Date</Label>
<p className="font-semibold text-gray-900 mt-1">
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'Not specified'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Confirmation Message */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<div className="flex items-start gap-3">
<Info className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-blue-900 mb-1">Ready to Submit</p>
<p className="text-sm text-blue-800">
Please review all the information above. Once submitted, your claim request will enter the approval workflow.
</p>
</div>
</div>
</div>
</div>
</motion.div>
);
default:
return null;
}
};
return (
<div className="w-full bg-gradient-to-br from-gray-50 to-gray-100 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-6 overflow-y-auto">
<div className="max-w-6xl mx-auto pb-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<Button
variant="ghost"
onClick={onBack}
className="mb-3 sm:mb-4 gap-2 text-sm sm:text-base"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span>
</Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div>
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900">New Claim Request</h1>
<p className="text-sm sm:text-base text-gray-600 mt-1">
Step {currentStep} of {totalSteps}: <span className="hidden sm:inline">{STEP_NAMES[currentStep - 1]}</span>
</p>
</div>
</div>
{/* Progress Bar */}
<div className="mt-4 sm:mt-6">
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((name, index) => (
<span
key={index}
className={`text-xs sm:text-sm ${
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
>
{index + 1}
</span>
))}
</div>
</div>
</div>
{/* Step Content */}
<Card className="mb-6 sm:mb-8">
<CardContent className="p-4 sm:p-6 lg:p-8">
<AnimatePresence mode="wait">
{renderStepContent()}
</AnimatePresence>
</CardContent>
</Card>
{/* Navigation */}
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-0 pb-4 sm:pb-0">
<Button
variant="outline"
onClick={prevStep}
disabled={currentStep === 1}
className="gap-2 w-full sm:w-auto order-2 sm:order-1"
>
<ArrowLeft className="w-4 h-4" />
Previous
</Button>
{currentStep < totalSteps ? (
<Button
onClick={nextStep}
disabled={!isStepValid()}
className="gap-2 w-full sm:w-auto order-1 sm:order-2"
>
Next
<ArrowRight className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!isStepValid()}
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
>
<Check className="w-4 h-4" />
Submit Claim Request
</Button>
)}
</div>
</div>
</div>
);
}

371
components/Dashboard.tsx Normal file
View File

@ -0,0 +1,371 @@
import React, { useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Progress } from './ui/progress';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import {
FileText,
Clock,
AlertTriangle,
TrendingUp,
CheckCircle,
Users,
Zap,
Shield,
ArrowRight,
Bell,
Star,
Activity,
Calendar,
Target,
Flame,
Settings
} from 'lucide-react';
interface DashboardProps {
onNavigate?: (page: string) => void;
onNewRequest?: () => void;
}
// Static data to prevent re-creation on each render
const STATS_DATA = [
{
title: 'Open Requests',
value: '24',
description: '+3 from last week',
icon: FileText,
trend: 'up',
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
change: '+12.5%'
},
{
title: 'Avg. SLA Compliance',
value: '87%',
description: '+5% from last month',
icon: Target,
trend: 'up',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
change: '+5.8%'
},
{
title: 'High Priority Alerts',
value: '5',
description: 'Requires immediate attention',
icon: Flame,
trend: 'neutral',
color: 'text-red-600',
bgColor: 'bg-red-50',
change: '-2'
},
{
title: 'Approved This Month',
value: '142',
description: '+12% from last month',
icon: CheckCircle,
trend: 'up',
color: 'text-green-600',
bgColor: 'bg-green-50',
change: '+18.2%'
}
];
const RECENT_ACTIVITY = [
{
id: 'RE-REQ-024',
title: 'Marketing Campaign Approval',
user: 'Sarah Chen',
action: 'approved',
time: '2 minutes ago',
avatar: 'SC',
priority: 'high'
},
{
id: 'RE-REQ-023',
title: 'Budget Allocation Request',
user: 'Mike Johnson',
action: 'commented',
time: '15 minutes ago',
avatar: 'MJ',
priority: 'medium'
},
{
id: 'RE-REQ-022',
title: 'Vendor Contract Review',
user: 'David Kumar',
action: 'submitted',
time: '1 hour ago',
avatar: 'DK',
priority: 'low'
},
{
id: 'RE-REQ-021',
title: 'IT Equipment Purchase',
user: 'Lisa Wong',
action: 'escalated',
time: '2 hours ago',
avatar: 'LW',
priority: 'high'
},
{
id: 'RE-REQ-020',
title: 'Office Space Lease',
user: 'John Doe',
action: 'rejected',
time: '3 hours ago',
avatar: 'JD',
priority: 'medium'
}
];
const HIGH_PRIORITY_REQUESTS = [
{ id: 'RE-REQ-001', title: 'Emergency Equipment Purchase', sla: '2 hours left', progress: 85 },
{ id: 'RE-REQ-005', title: 'Critical Vendor Agreement', sla: '4 hours left', progress: 60 },
{ id: 'RE-REQ-012', title: 'Urgent Marketing Approval', sla: '6 hours left', progress: 40 }
];
// Utility functions outside component
const getActionColor = (action: string) => {
switch (action) {
case 'approved': return 'text-emerald-700 bg-emerald-100 border-emerald-200';
case 'rejected': return 'text-red-700 bg-red-100 border-red-200';
case 'commented': return 'text-blue-700 bg-blue-100 border-blue-200';
case 'escalated': return 'text-orange-700 bg-orange-100 border-orange-200';
case 'submitted': return 'text-purple-700 bg-purple-100 border-purple-200';
default: return 'text-gray-700 bg-gray-100 border-gray-200';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'bg-red-100 text-red-800 border-red-200';
case 'medium': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'low': return 'bg-green-100 text-green-800 border-green-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
// Memoize quick actions to prevent recreation on each render
const quickActions = useMemo(() => [
{ label: 'New Request', icon: FileText, action: () => onNewRequest?.(), color: 'bg-emerald-600 hover:bg-emerald-700' },
{ label: 'View Pending', icon: Clock, action: () => onNavigate?.('open-requests'), color: 'bg-blue-600 hover:bg-blue-700' },
{ label: 'Reports', icon: Activity, action: () => {}, color: 'bg-purple-600 hover:bg-purple-700' },
{ label: 'Settings', icon: Settings, action: () => {}, color: 'bg-slate-600 hover:bg-slate-700' }
], [onNavigate, onNewRequest]);
return (
<div className="space-y-6 max-w-7xl mx-auto p-4">
{/* Hero Section with Clear Background */}
<Card className="relative overflow-hidden shadow-xl border-0">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl font-bold mb-2 text-white">Welcome to Royal Enfield Portal</h1>
<p className="text-xl text-gray-200">Rev up your workflow with streamlined approvals</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
{quickActions.map((action, index) => (
<Button
key={index}
onClick={action.action}
className={`${action.color} text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200`}
size="lg"
>
<action.icon className="w-5 h-5 mr-2" />
{action.label}
</Button>
))}
</div>
</div>
{/* Decorative Elements */}
<div className="hidden lg:flex items-center gap-4">
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
<Zap className="w-8 h-8 text-yellow-400" />
</div>
</div>
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center">
<Activity className="w-6 h-6 text-white/80" />
</div>
</div>
</div>
</CardContent>
</Card>
{/* Stats Cards with Better Contrast */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{STATS_DATA.map((stat, index) => (
<Card key={index} className="hover:shadow-xl transition-all duration-300 hover:scale-105 cursor-pointer shadow-lg">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<div className={`p-3 rounded-lg ${stat.bgColor} shadow-sm`}>
<stat.icon className={`h-5 w-5 ${stat.color}`} />
</div>
</CardHeader>
<CardContent>
<div className="flex items-end justify-between">
<div>
<div className="text-3xl font-bold text-gray-900 mb-1">{stat.value}</div>
<div className="flex items-center gap-2">
{stat.trend === 'up' && (
<div className="flex items-center gap-1 text-emerald-600">
<TrendingUp className="h-3 w-3" />
<span className="text-xs font-medium">{stat.change}</span>
</div>
)}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground hover:text-emerald-600 transition-colors" />
</div>
<p className="text-xs text-muted-foreground mt-2">{stat.description}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* High Priority Alerts */}
<Card className="lg:col-span-1 shadow-lg hover:shadow-xl transition-shadow">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-red-100 rounded-lg">
<Flame className="h-5 w-5 text-red-600" />
</div>
<div>
<CardTitle className="text-lg text-gray-900">Critical Alerts</CardTitle>
<CardDescription className="text-gray-600">Urgent attention required</CardDescription>
</div>
</div>
<Badge variant="destructive" className="animate-pulse font-semibold">
{HIGH_PRIORITY_REQUESTS.length}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{HIGH_PRIORITY_REQUESTS.map((request) => (
<div key={request.id} className="p-4 bg-red-50 rounded-xl border border-red-100 hover:shadow-md transition-all duration-200">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-semibold text-sm text-gray-900">{request.id}</p>
<Star className="h-3 w-3 text-red-500" />
</div>
<p className="text-sm text-gray-700">{request.title}</p>
</div>
<Badge variant="outline" className="text-xs bg-white border-red-200 text-red-700 font-medium">
{request.sla}
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs text-gray-600">
<span>Progress</span>
<span className="font-medium">{request.progress}%</span>
</div>
<Progress
value={request.progress}
className="h-2"
/>
</div>
</div>
))}
<Button
variant="outline"
className="w-full mt-4 border-red-200 text-red-700 hover:bg-red-50 hover:border-red-300 font-medium"
onClick={() => onNavigate?.('open-requests')}
>
<AlertTriangle className="w-4 h-4 mr-2" />
View All Critical Items
</Button>
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="lg:col-span-2 shadow-lg hover:shadow-xl transition-shadow">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 rounded-lg">
<Activity className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg text-gray-900">Recent Activity</CardTitle>
<CardDescription className="text-gray-600">Latest updates across all requests</CardDescription>
</div>
</div>
<Button variant="ghost" size="sm" className="text-blue-600 hover:bg-blue-50 font-medium">
<Calendar className="w-4 h-4 mr-2" />
View All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{RECENT_ACTIVITY.map((activity) => (
<div
key={activity.id}
className="flex items-center gap-4 p-4 rounded-xl hover:bg-gray-50 transition-all duration-200 cursor-pointer border border-transparent hover:border-gray-200"
>
<div className="relative">
<Avatar className="h-10 w-10 ring-2 ring-white shadow-md">
<AvatarImage src="" />
<AvatarFallback className="bg-slate-700 text-white font-semibold">
{activity.avatar}
</AvatarFallback>
</Avatar>
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-white rounded-full flex items-center justify-center shadow-sm">
{activity.action === 'approved' && <CheckCircle className="w-3 h-3 text-green-600" />}
{activity.action === 'rejected' && <AlertTriangle className="w-3 h-3 text-red-600" />}
{activity.action === 'commented' && <Bell className="w-3 h-3 text-blue-600" />}
{activity.action === 'escalated' && <Flame className="w-3 h-3 text-orange-600" />}
{activity.action === 'submitted' && <FileText className="w-3 h-3 text-purple-600" />}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-semibold text-sm text-gray-900">{activity.id}</span>
<Badge
variant="outline"
className={`text-xs border ${getActionColor(activity.action)} font-medium`}
>
{activity.action}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getPriorityColor(activity.priority)} font-medium`}
>
{activity.priority}
</Badge>
</div>
<p className="text-sm text-gray-700 line-clamp-1 mb-1">{activity.title}</p>
<p className="text-xs text-muted-foreground">
by <span className="font-medium text-gray-900">{activity.user}</span> {activity.time}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground hover:text-blue-600 transition-colors" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

155
components/Layout.tsx Normal file
View File

@ -0,0 +1,155 @@
import React from 'react';
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarTrigger } from './ui/sidebar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
interface LayoutProps {
children: React.ReactNode;
currentPage?: string;
onNavigate?: (page: string) => void;
onNewRequest?: () => void;
}
export function Layout({ children, currentPage = 'dashboard', onNavigate, onNewRequest }: LayoutProps) {
const menuItems = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
];
return (
<SidebarProvider>
<div className="min-h-screen flex w-full bg-background">
<Sidebar className="border-r border-sidebar-border">
<SidebarHeader className="p-3 lg:p-4 border-b border-sidebar-border">
<div className="flex items-center gap-2 lg:gap-3">
<div className="w-8 h-8 lg:w-10 lg:h-10 bg-re-green rounded-lg flex items-center justify-center shrink-0">
<div className="w-5 h-5 lg:w-6 lg:h-6 bg-re-gold rounded-full"></div>
</div>
<div className="min-w-0 flex-1">
<h2 className="text-sm lg:text-base font-semibold text-sidebar-foreground truncate">Royal Enfield</h2>
<p className="text-xs lg:text-sm text-sidebar-foreground/70 truncate">Approval Portal</p>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-2 lg:p-3">
<SidebarMenu className="space-y-1 lg:space-y-2">
{menuItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
onClick={() => onNavigate?.(item.id)}
isActive={currentPage === item.id}
className="w-full justify-start text-sidebar-foreground hover:bg-sidebar-accent transition-colors text-sm lg:text-base"
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="truncate">{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
{/* Quick Action in Sidebar */}
<div className="mt-4 lg:mt-6 p-2 lg:p-3 bg-re-green/10 rounded-lg border border-re-green/20">
<Button
onClick={onNewRequest}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-xs lg:text-sm"
size="sm"
>
<Plus className="w-3 h-3 lg:w-4 lg:h-4 mr-1 lg:mr-2" />
<span className="hidden sm:inline">Raise New Request</span>
<span className="sm:hidden">New</span>
</Button>
</div>
</SidebarContent>
</Sidebar>
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="h-14 lg:h-16 border-b border-border bg-card flex items-center justify-between px-3 lg:px-6 shrink-0">
<div className="flex items-center gap-2 lg:gap-4 min-w-0 flex-1">
<SidebarTrigger />
<div className="relative max-w-xs lg:max-w-md flex-1 lg:flex-none">
<Search className="absolute left-2 lg:left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-3 h-3 lg:w-4 lg:h-4" />
<Input
placeholder="Search..."
className="pl-8 lg:pl-10 bg-input-background border-border w-full text-sm lg:text-base h-8 lg:h-10"
/>
</div>
</div>
<div className="flex items-center gap-1 lg:gap-4 shrink-0">
<Button
onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-1 lg:gap-2 hidden md:flex text-sm lg:text-base"
size="sm"
>
<Plus className="w-3 h-3 lg:w-4 lg:h-4" />
<span className="hidden lg:inline">New Request</span>
<span className="lg:hidden">New</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative shrink-0 h-8 w-8 lg:h-10 lg:w-10">
<Bell className="w-4 h-4 lg:w-5 lg:h-5" />
<Badge className="absolute -top-1 -right-1 w-4 h-4 lg:w-5 lg:h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0">
3
</Badge>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 lg:w-80">
<div className="p-3 border-b">
<h4 className="font-semibold text-sm lg:text-base">Notifications</h4>
</div>
<div className="p-3 space-y-2">
<div className="text-sm">
<p className="font-medium">RE-REQ-001 needs approval</p>
<p className="text-muted-foreground text-xs">SLA expires in 2 hours</p>
</div>
<div className="text-sm">
<p className="font-medium">New comment on RE-REQ-003</p>
<p className="text-muted-foreground text-xs">From John Doe - 5 min ago</p>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="cursor-pointer shrink-0 h-8 w-8 lg:h-10 lg:w-10">
<AvatarImage src="" />
<AvatarFallback className="bg-re-green text-white text-xs lg:text-sm">JD</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<User className="w-4 h-4 mr-2" />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="w-4 h-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuItem>
<LogOut className="w-4 h-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
{/* Main Content */}
<main className="flex-1 p-3 lg:p-6 overflow-auto min-w-0">
{children}
</main>
</div>
</div>
</SidebarProvider>
);
}

419
components/MyRequests.tsx Normal file
View File

@ -0,0 +1,419 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Badge } from './ui/badge';
import { Avatar, AvatarFallback } from './ui/avatar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import {
FileText,
Search,
Filter,
Clock,
CheckCircle,
XCircle,
AlertCircle,
TrendingUp,
Calendar,
User,
ArrowRight,
MoreHorizontal,
Eye,
Edit,
Copy
} from 'lucide-react';
import { motion } from 'motion/react';
interface MyRequestsProps {
onViewRequest: (requestId: string, requestTitle?: string) => void;
dynamicRequests?: any[];
}
// Mock data for user's requests
const MY_REQUESTS_DATA = [
{
id: 'RE-REQ-2024-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign',
status: 'pending',
priority: 'standard',
department: 'Marketing - West Zone',
submittedDate: '2024-10-07',
currentApprover: 'Royal Motors Mumbai (Dealer)',
approverLevel: '1 of 8',
dueDate: '2024-10-16',
templateType: 'claim-management',
templateName: 'Claim Management',
estimatedCompletion: '2024-10-16'
},
{
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
description: 'Request for Q4 marketing campaign budget allocation for new motorcycle launch',
status: 'pending',
priority: 'express',
department: 'Marketing',
submittedDate: '2024-10-05',
currentApprover: 'Sarah Johnson',
approverLevel: '2 of 3',
dueDate: '2024-10-12'
},
{
id: 'RE-REQ-002',
title: 'IT Equipment Purchase Request',
description: 'New laptops and workstations for the development team',
status: 'approved',
priority: 'standard',
submittedDate: '2024-09-28',
currentApprover: 'Completed',
approverLevel: '3 of 3',
dueDate: '2024-10-01'
},
{
id: 'RE-REQ-003',
title: 'Training Program Authorization',
description: 'Employee skill development program for technical team',
status: 'in-review',
priority: 'standard',
submittedDate: '2024-10-03',
currentApprover: 'Michael Chen',
approverLevel: '1 of 2',
estimatedCompletion: '2024-10-10'
},
{
id: 'RE-REQ-004',
title: 'Vendor Contract Renewal',
description: 'Annual renewal for supply chain vendor contracts',
status: 'rejected',
priority: 'express',
submittedDate: '2024-09-25',
currentApprover: 'Rejected by Alex Kumar',
approverLevel: '1 of 3',
estimatedCompletion: 'N/A'
},
{
id: 'RE-REQ-005',
title: 'Office Space Renovation',
description: 'Workspace renovation for improved employee experience',
status: 'draft',
priority: 'standard',
submittedDate: '2024-10-07',
currentApprover: 'Not submitted',
approverLevel: '0 of 2',
estimatedCompletion: 'Pending submission'
}
];
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-4 h-4 text-green-600" />;
case 'rejected':
return <XCircle className="w-4 h-4 text-red-600" />;
case 'pending':
return <Clock className="w-4 h-4 text-yellow-600" />;
case 'in-review':
return <AlertCircle className="w-4 h-4 text-blue-600" />;
case 'draft':
return <Edit className="w-4 h-4 text-gray-600" />;
default:
return <FileText className="w-4 h-4 text-gray-600" />;
}
};
const getStatusBadge = (status: string) => {
const baseClasses = "text-xs font-medium px-2 py-1 rounded-full";
switch (status) {
case 'approved':
return <Badge className={`${baseClasses} bg-green-100 text-green-800 border-green-200`}>Approved</Badge>;
case 'rejected':
return <Badge className={`${baseClasses} bg-red-100 text-red-800 border-red-200`}>Rejected</Badge>;
case 'pending':
return <Badge className={`${baseClasses} bg-yellow-100 text-yellow-800 border-yellow-200`}>Pending</Badge>;
case 'in-review':
return <Badge className={`${baseClasses} bg-blue-100 text-blue-800 border-blue-200`}>In Review</Badge>;
case 'draft':
return <Badge className={`${baseClasses} bg-gray-100 text-gray-800 border-gray-200`}>Draft</Badge>;
default:
return <Badge className={`${baseClasses} bg-gray-100 text-gray-800 border-gray-200`}>Unknown</Badge>;
}
};
const getPriorityBadge = (priority: string) => {
switch (priority) {
case 'express':
return <Badge variant="destructive" className="text-xs">Express</Badge>;
case 'standard':
return <Badge className="bg-blue-100 text-blue-800 border-blue-200 text-xs">Standard</Badge>;
default:
return <Badge variant="secondary" className="text-xs">Normal</Badge>;
}
};
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
// Convert dynamic requests to the format expected by this component
const convertedDynamicRequests = dynamicRequests.map(req => ({
id: req.id,
title: req.title,
description: req.description,
status: req.status,
priority: req.priority,
department: req.department,
submittedDate: new Date(req.createdAt).toISOString().split('T')[0],
currentApprover: req.approvalFlow?.[0]?.approver || 'Current User (Initiator)',
approverLevel: `${req.currentStep} of ${req.totalSteps}`,
dueDate: new Date(req.dueDate).toISOString().split('T')[0],
templateType: req.templateType,
templateName: req.templateName
}));
// Merge static mock data with dynamic requests (dynamic requests first)
const allRequests = [...convertedDynamicRequests, ...MY_REQUESTS_DATA];
const [priorityFilter, setPriorityFilter] = useState('all');
// Filter requests based on search and filters
const filteredRequests = allRequests.filter(request => {
const matchesSearch =
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
// Stats calculation
const stats = {
total: allRequests.length,
pending: allRequests.filter(r => r.status === 'pending').length,
approved: allRequests.filter(r => r.status === 'approved').length,
inReview: allRequests.filter(r => r.status === 'in-review').length,
rejected: allRequests.filter(r => r.status === 'rejected').length,
draft: allRequests.filter(r => r.status === 'draft').length
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-900 flex items-center gap-2">
<User className="w-7 h-7 text-[--re-green]" />
My Requests
</h1>
<p className="text-gray-600 mt-1">
Track and manage all your submitted requests
</p>
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-700 font-medium">Total</p>
<p className="text-2xl font-bold text-blue-900">{stats.total}</p>
</div>
<FileText className="w-8 h-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-orange-700 font-medium">In Progress</p>
<p className="text-2xl font-bold text-orange-900">{stats.pending + stats.inReview}</p>
</div>
<Clock className="w-8 h-8 text-orange-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-green-100 border-green-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-700 font-medium">Approved</p>
<p className="text-2xl font-bold text-green-900">{stats.approved}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-50 to-red-100 border-red-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-700 font-medium">Rejected</p>
<p className="text-2xl font-bold text-red-900">{stats.rejected}</p>
</div>
<XCircle className="w-8 h-8 text-red-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-700 font-medium">Draft</p>
<p className="text-2xl font-bold text-gray-900">{stats.draft}</p>
</div>
<Edit className="w-8 h-8 text-gray-600" />
</div>
</CardContent>
</Card>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests by title, description, or ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in-review">In Review</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="w-32">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Requests List */}
<div className="space-y-4">
{filteredRequests.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No requests found</h3>
<p className="text-gray-600">
{searchTerm || statusFilter !== 'all' || priorityFilter !== 'all'
? 'Try adjusting your search or filters'
: 'You haven\'t created any requests yet'}
</p>
</CardContent>
</Card>
) : (
filteredRequests.map((request, index) => (
<motion.div
key={request.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="hover:shadow-lg transition-all duration-200 cursor-pointer group"
onClick={() => onViewRequest(request.id, request.title)}>
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-start gap-4 flex-1">
<div className="flex items-center gap-2">
{getStatusIcon(request.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<h3 className="font-semibold text-gray-900 group-hover:text-[--re-green] transition-colors">
{request.title}
</h3>
{getStatusBadge(request.status)}
{getPriorityBadge(request.priority)}
{(request as any).templateType && (
<Badge className="text-xs bg-purple-100 text-purple-800 border-purple-200">
<FileText className="w-3 h-3 mr-1" />
Template: {(request as any).templateName}
</Badge>
)}
</div>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{request.description}
</p>
<div className="flex items-center gap-6 text-xs text-gray-500">
<div className="flex items-center gap-1">
<span className="font-medium text-gray-700">ID:</span>
<span className="font-mono">{request.id}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
<span>Submitted: {new Date(request.submittedDate).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<ArrowRight className="w-4 h-4 text-gray-400 group-hover:text-[--re-green] transition-colors" />
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
<span className="text-gray-600">
Current: <span className="font-medium text-gray-900">{request.currentApprover}</span>
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-gray-400" />
<span className="text-gray-600">
Level: <span className="font-medium text-gray-900">{request.approverLevel}</span>
</span>
</div>
</div>
<div className="text-sm text-gray-600">
<span className="font-medium">Estimated completion:</span> {request.estimatedCompletion}
</div>
</div>
</CardContent>
</Card>
</motion.div>
))
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,660 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { toast } from 'sonner@2.0.3';
import { Progress } from './ui/progress';
import { Avatar, AvatarFallback } from './ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { CUSTOM_REQUEST_DATABASE } from '../utils/customRequestDatabase';
import {
ArrowLeft,
Clock,
User,
FileText,
MessageSquare,
Users,
CheckCircle,
XCircle,
Download,
Eye,
Flame,
Target,
TrendingUp,
RefreshCw,
Activity,
Mail,
Phone,
Upload,
UserPlus,
Edit3,
ClipboardList
} from 'lucide-react';
interface RequestDetailProps {
requestId: string;
onBack?: () => void;
onOpenModal?: (modal: string) => void;
dynamicRequests?: any[];
}
// Utility functions
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
case 'urgent':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'urgent priority'
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'standard priority'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'normal priority'
};
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'pending'
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'in-review'
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
label: 'approved'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'rejected'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: status
};
}
};
const getSLAConfig = (progress: number) => {
if (progress >= 80) {
return {
bg: 'bg-red-50',
color: 'bg-red-500',
textColor: 'text-red-700'
};
} else if (progress >= 60) {
return {
bg: 'bg-orange-50',
color: 'bg-orange-500',
textColor: 'text-orange-700'
};
} else {
return {
bg: 'bg-green-50',
color: 'bg-green-500',
textColor: 'text-green-700'
};
}
};
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'pending':
case 'in-review':
return <Clock className="w-5 h-5 text-blue-600" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
const getActionTypeIcon = (type: string) => {
switch (type) {
case 'approval':
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejection':
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'comment':
return <MessageSquare className="w-5 h-5 text-blue-600" />;
case 'status_change':
case 'updated':
return <RefreshCw className="w-5 h-5 text-orange-600" />;
case 'assignment':
return <UserPlus className="w-5 h-5 text-purple-600" />;
case 'created':
return <FileText className="w-5 h-5 text-blue-600" />;
case 'reminder':
return <Clock className="w-5 h-5 text-yellow-600" />;
default:
return <Activity className="w-5 h-5 text-gray-600" />;
}
};
export function RequestDetail({
requestId,
onBack,
onOpenModal,
dynamicRequests = []
}: RequestDetailProps) {
const [activeTab, setActiveTab] = useState('overview');
// Get request from custom request database or dynamic requests
const request = useMemo(() => {
// First check static database
const staticRequest = CUSTOM_REQUEST_DATABASE[requestId];
if (staticRequest) return staticRequest;
// Then check dynamic requests
const dynamicRequest = dynamicRequests.find((req: any) => req.id === requestId);
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestId, dynamicRequests]);
if (!request) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Request Not Found</h2>
<p className="text-gray-600 mb-4">The custom request you're looking for doesn't exist.</p>
<Button onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Go Back
</Button>
</div>
</div>
);
}
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const slaConfig = getSLAConfig(request.slaProgress);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6">
{/* Header Section */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
{/* Top Header */}
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="rounded-lg"
>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-gray-900">{request.id}</h1>
<Badge className={`${priorityConfig.color} rounded-full px-3`} variant="outline">
{priorityConfig.label}
</Badge>
<Badge className={`${statusConfig.color} rounded-full px-3`} variant="outline">
{statusConfig.label}
</Badge>
</div>
</div>
</div>
<Button variant="outline" size="sm" className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
</div>
<div className="mt-3 ml-14">
<h2 className="text-xl font-semibold text-gray-900">{request.title}</h2>
</div>
</div>
{/* SLA Progress */}
<div className={`${slaConfig.bg} px-6 py-4`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${slaConfig.textColor}`} />
<span className="text-sm font-medium text-gray-900">SLA Progress</span>
</div>
<span className={`text-sm font-semibold ${slaConfig.textColor}`}>
{request.slaRemaining}
</span>
</div>
<Progress value={request.slaProgress} className="h-2 mb-2" />
<p className="text-xs text-gray-600">
Due: {request.slaEndDate} {request.slaProgress}% elapsed
</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-white border border-gray-200 shadow-sm mb-6">
<TabsTrigger value="overview" className="gap-2">
<ClipboardList className="w-4 h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="workflow" className="gap-2">
<TrendingUp className="w-4 h-4" />
Workflow
</TabsTrigger>
<TabsTrigger value="documents" className="gap-2">
<FileText className="w-4 h-4" />
Documents
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<Activity className="w-4 h-4" />
Activity
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
{/* Request Initiator */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<User className="w-5 h-5 text-blue-600" />
Request Initiator
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-12 w-12 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-gray-700 text-white font-semibold">
{request.initiator?.avatar || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{request.initiator?.name || 'N/A'}</h3>
<p className="text-sm text-gray-600">{request.initiator?.role || 'N/A'}</p>
<p className="text-sm text-gray-500">{request.initiator?.department || 'N/A'}</p>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{request.initiator?.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{request.initiator?.phone || 'N/A'}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Request Details */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="w-5 h-5 text-blue-600" />
Request Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-700 block mb-2">Description</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
{request.description}
</p>
</div>
</div>
{/* Additional Details */}
{(request.category || request.subcategory) && (
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200">
{request.category && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Category</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.category}</p>
</div>
)}
{request.subcategory && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Subcategory</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.subcategory}</p>
</div>
)}
</div>
)}
{request.amount && (
<div className="pt-4 border-t border-gray-200">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Amount</label>
<p className="text-lg font-bold text-gray-900 mt-1">{request.amount}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Created</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.createdAt}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Updated</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.updatedAt}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Quick Actions Sidebar (1/3 width) */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start gap-2 bg-[#1a472a] text-white hover:bg-[#152e1f] hover:text-white border-0"
onClick={() => onOpenModal?.('work-note')}
>
<MessageSquare className="w-4 h-4" />
Add Work Note
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300 hover:bg-gray-50"
onClick={() => onOpenModal?.('add-approver')}
>
<UserPlus className="w-4 h-4" />
Add Approver
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 border-gray-300 hover:bg-gray-50"
onClick={() => onOpenModal?.('add-spectator')}
>
<Eye className="w-4 h-4" />
Add Spectator
</Button>
<div className="pt-4 space-y-2">
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Request
</Button>
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Request
</Button>
</div>
</CardContent>
</Card>
{/* Spectators */}
{request.spectators && request.spectators.length > 0 && (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">Spectators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{request.spectators.map((spectator: any, index: number) => (
<div key={index} className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{spectator.name}</p>
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Workflow Tab */}
<TabsContent value="workflow">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-blue-600" />
Approval Workflow
</CardTitle>
<CardDescription className="mt-2">
Track the approval progress through each step
</CardDescription>
</div>
{request.totalSteps && (
<Badge variant="outline" className="font-medium">
Step {request.currentStep} of {request.totalSteps}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{request.approvalFlow && request.approvalFlow.length > 0 ? (
<div className="space-y-4">
{request.approvalFlow.map((step: any, index: number) => {
const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected';
return (
<div
key={index}
className={`relative p-5 rounded-lg border-2 transition-all ${
isActive
? 'border-blue-500 bg-blue-50 shadow-md'
: isCompleted
? 'border-green-500 bg-green-50'
: isRejected
? 'border-red-500 bg-red-50'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-start gap-4">
<div className={`p-3 rounded-xl ${
isActive ? 'bg-blue-100' :
isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' :
'bg-gray-100'
}`}>
{getStepIcon(step.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">
{step.step ? `Step ${step.step}: ` : ''}{step.role}
</h4>
<Badge variant="outline" className={
isActive ? 'bg-blue-100 text-blue-800 border-blue-200' :
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
'bg-gray-100 text-gray-800 border-gray-200'
}>
{step.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{step.approver}</p>
</div>
<div className="text-right">
{step.tatHours && (
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
)}
{step.elapsedHours !== undefined && step.elapsedHours > 0 && (
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
)}
{step.actualHours !== undefined && (
<p className="text-xs text-gray-600 font-medium">Completed in: {step.actualHours}h</p>
)}
</div>
</div>
{step.comment && (
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{step.comment}</p>
</div>
)}
{step.timestamp && (
<p className="text-xs text-gray-500 mt-2">
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {step.timestamp}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No workflow steps defined</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
Request Documents
</CardTitle>
<Button size="sm">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
</CardHeader>
<CardContent>
{request.documents && request.documents.length > 0 ? (
<div className="space-y-3">
{request.documents.map((doc: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">{doc.name}</p>
<p className="text-xs text-gray-500">
{doc.size} Uploaded by {doc.uploadedBy} on {doc.uploadedAt}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<Eye className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm">
<Download className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8">No documents uploaded yet</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Activity Tab */}
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-orange-600" />
Activity Timeline
</CardTitle>
<CardDescription>
Complete audit trail of all request activities
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{request.auditTrail && request.auditTrail.length > 0 ? request.auditTrail.map((entry: any, index: number) => (
<div key={index} className="flex items-start gap-4 p-4 rounded-lg hover:bg-gray-50 transition-colors border border-gray-100">
<div className="mt-1">
{getActionTypeIcon(entry.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-gray-900">{entry.action}</p>
<p className="text-sm text-gray-600 mt-1">{entry.details}</p>
<p className="text-xs text-gray-500 mt-1">by {entry.user}</p>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">{entry.timestamp}</span>
</div>
</div>
</div>
)) : (
<div className="text-center py-12">
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-sm text-gray-500">No activity recorded yet</p>
<p className="text-xs text-gray-400 mt-2">Actions and updates will appear here</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
}

645
components/RequestsList.tsx Normal file
View File

@ -0,0 +1,645 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Progress } from './ui/progress';
import {
Calendar,
Clock,
Filter,
Search,
User,
FileText,
AlertCircle,
CheckCircle,
ArrowRight,
SortAsc,
SortDesc,
MoreHorizontal,
Flame,
Target,
Eye,
Users,
TrendingUp,
RefreshCw,
Download,
Settings2,
X
} from 'lucide-react';
interface Request {
id: string;
title: string;
description: string;
status: 'pending' | 'approved' | 'rejected' | 'in-review';
priority: 'express' | 'standard';
initiator: {
name: string;
avatar: string;
};
currentApprover?: {
name: string;
avatar: string;
};
slaProgress: number;
slaRemaining: string;
createdAt: string;
dueDate: string;
approvalStep: string;
reason?: string;
department?: string;
}
interface RequestsListProps {
type: 'open' | 'closed';
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
// Static data to prevent re-creation on each render
const MOCK_REQUESTS: Request[] = [
{
id: 'RE-REQ-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign using Claim Management template workflow.',
status: 'pending',
priority: 'standard',
initiator: { name: 'Sneha Patil', avatar: 'SP' },
currentApprover: { name: 'Sneha Patil (Initiator)', avatar: 'SP' },
slaProgress: 35,
slaRemaining: '4 days 12 hours',
createdAt: '2024-10-07',
dueDate: '2024-10-16',
approvalStep: 'Initiator Review & Confirmation',
department: 'Marketing - West Zone'
} as any,
{
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
description: 'Request for Q4 marketing campaign budget allocation of $50,000 for digital advertising across social media platforms and content creation.',
status: 'pending',
priority: 'express',
initiator: { name: 'Sarah Chen', avatar: 'SC' },
currentApprover: { name: 'Mike Johnson', avatar: 'MJ' },
slaProgress: 85,
slaRemaining: '2 hours',
createdAt: '2024-10-07',
dueDate: '2024-10-09',
approvalStep: 'Awaiting Finance Approval',
department: 'Marketing'
},
{
id: 'RE-REQ-002',
title: 'IT Equipment Purchase',
description: 'Purchase of 10 new laptops for the development team including software licenses and accessories for enhanced productivity.',
status: 'in-review',
priority: 'standard',
initiator: { name: 'David Kumar', avatar: 'DK' },
currentApprover: { name: 'Lisa Wong', avatar: 'LW' },
slaProgress: 45,
slaRemaining: '1 day',
createdAt: '2024-10-06',
dueDate: '2024-10-12',
approvalStep: 'IT Department Review'
},
{
id: 'RE-REQ-003',
title: 'Vendor Contract Renewal',
description: 'Annual renewal of cleaning services contract with updated terms and pricing structure for office maintenance.',
status: 'pending',
priority: 'standard',
initiator: { name: 'John Doe', avatar: 'JD' },
currentApprover: { name: 'Anna Smith', avatar: 'AS' },
slaProgress: 90,
slaRemaining: '30 minutes',
createdAt: '2024-10-05',
dueDate: '2024-10-08',
approvalStep: 'Final Management Approval'
},
{
id: 'RE-REQ-004',
title: 'Office Space Expansion',
description: 'Lease additional office space for growing team, 2000 sq ft in the same building with modern amenities.',
status: 'in-review',
priority: 'express',
initiator: { name: 'Lisa Wong', avatar: 'LW' },
currentApprover: { name: 'David Kumar', avatar: 'DK' },
slaProgress: 30,
slaRemaining: '3 days',
createdAt: '2024-10-04',
dueDate: '2024-10-15',
approvalStep: 'Legal Review'
},
{
id: 'RE-REQ-005',
title: 'Employee Training Program',
description: 'Approval for new employee onboarding and skill development training program with external consultants.',
status: 'pending',
priority: 'standard',
initiator: { name: 'Anna Smith', avatar: 'AS' },
currentApprover: { name: 'Sarah Chen', avatar: 'SC' },
slaProgress: 60,
slaRemaining: '12 hours',
createdAt: '2024-10-03',
dueDate: '2024-10-11',
approvalStep: 'HR Approval'
}
];
const CLOSED_REQUESTS: Request[] = [
{
...MOCK_REQUESTS[0],
status: 'approved',
reason: 'Budget approved with quarterly review conditions'
},
{
...MOCK_REQUESTS[1],
status: 'approved',
reason: 'All equipment approved and ordered through preferred vendor'
},
{
...MOCK_REQUESTS[2],
status: 'rejected',
reason: 'Pricing not competitive, seek alternative vendors'
},
{
...MOCK_REQUESTS[3],
status: 'approved',
reason: 'Lease terms negotiated and approved by legal team'
},
{
...MOCK_REQUESTS[4],
status: 'approved',
reason: 'Program approved with budget adjustments'
}
];
// Utility functions
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: Flame,
iconColor: 'text-red-600'
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Target,
iconColor: 'text-blue-600'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Target,
iconColor: 'text-gray-600'
};
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
icon: Clock,
iconColor: 'text-yellow-600'
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Eye,
iconColor: 'text-blue-600'
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle,
iconColor: 'text-green-600'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: AlertCircle,
iconColor: 'text-red-600'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: AlertCircle,
iconColor: 'text-gray-600'
};
}
};
const getSLAUrgency = (progress: number) => {
if (progress >= 80) return { color: 'bg-red-500', textColor: 'text-red-600', urgency: 'critical' };
if (progress >= 60) return { color: 'bg-orange-500', textColor: 'text-orange-600', urgency: 'warning' };
return { color: 'bg-green-500', textColor: 'text-green-600', urgency: 'normal' };
};
export function RequestsList({ type, onViewRequest }: RequestsListProps) {
const [searchTerm, setSearchTerm] = useState('');
const [priorityFilter, setPriorityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>('due');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const sourceRequests = type === 'open' ? MOCK_REQUESTS : CLOSED_REQUESTS;
const filteredAndSortedRequests = useMemo(() => {
let filtered = sourceRequests.filter(request => {
const matchesSearch =
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.initiator.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
return matchesSearch && matchesPriority && matchesStatus;
});
// Sort requests
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sortBy) {
case 'created':
aValue = new Date(a.createdAt);
bValue = new Date(b.createdAt);
break;
case 'due':
aValue = new Date(a.dueDate);
bValue = new Date(b.dueDate);
break;
case 'priority':
const priorityOrder = { express: 2, standard: 1 };
aValue = priorityOrder[a.priority as keyof typeof priorityOrder];
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
break;
case 'sla':
aValue = a.slaProgress;
bValue = b.slaProgress;
break;
default:
return 0;
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
return filtered;
}, [sourceRequests, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
const clearFilters = () => {
setSearchTerm('');
setPriorityFilter('all');
setStatusFilter('all');
};
const activeFiltersCount = [
searchTerm,
priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null
].filter(Boolean).length;
return (
<div className="space-y-6 p-6 max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center shadow-lg">
<FileText className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">
{type === 'open' ? 'Open Requests' : 'Closed Requests'}
</h1>
<p className="text-gray-600">
{type === 'open'
? 'Manage and track active approval requests'
: 'Review completed and archived requests'
}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-lg px-4 py-2 bg-slate-100 text-slate-800 font-semibold">
{filteredAndSortedRequests.length} {type} requests
</Badge>
<Button variant="outline" size="sm" className="gap-2">
<RefreshCw className="w-4 h-4" />
Refresh
</Button>
</div>
</div>
{/* Enhanced Filters Section */}
<Card className="shadow-lg border-0">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Filter className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg">Filters & Search</CardTitle>
<CardDescription>
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-red-600 hover:bg-red-50 gap-1"
>
<X className="w-3 h-3" />
Clear
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className="gap-2"
>
<Settings2 className="w-4 h-4" />
{showAdvancedFilters ? 'Basic' : 'Advanced'}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Primary filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests, IDs, or initiators..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-11 bg-gray-50 border-gray-200 focus:bg-white transition-colors"
/>
</div>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="h-11 bg-gray-50 border-gray-200 focus:bg-white">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">🔥 Express</SelectItem>
<SelectItem value="standard">🎯 Standard</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-11 bg-gray-50 border-gray-200 focus:bg-white">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
{type === 'open' ? (
<>
<SelectItem value="pending"> Pending</SelectItem>
<SelectItem value="in-review">👁 In Review</SelectItem>
</>
) : (
<>
<SelectItem value="approved"> Approved</SelectItem>
<SelectItem value="rejected"> Rejected</SelectItem>
</>
)}
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
<SelectTrigger className="h-11 bg-gray-50 border-gray-200 focus:bg-white">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
{type === 'open' && <SelectItem value="sla">SLA Progress</SelectItem>}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-3 h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-4 h-4" /> : <SortDesc className="w-4 h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Requests List */}
<div className="space-y-4">
{filteredAndSortedRequests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const slaConfig = getSLAUrgency(request.slaProgress);
return (
<Card
key={request.id}
className="group hover:shadow-xl transition-all duration-300 cursor-pointer border-0 shadow-md hover:scale-[1.01]"
onClick={() => onViewRequest?.(request.id, request.title)}
>
<CardContent className="p-6">
<div className="flex items-start gap-6">
{/* Priority Indicator */}
<div className="flex flex-col items-center gap-2 pt-1">
<div className={`p-3 rounded-xl ${priorityConfig.color} border`}>
<priorityConfig.icon className={`w-5 h-5 ${priorityConfig.iconColor}`} />
</div>
<Badge
variant="outline"
className={`text-xs font-medium ${priorityConfig.color} capitalize`}
>
{request.priority}
</Badge>
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{request.id}
</h3>
<Badge
variant="outline"
className={`${statusConfig.color} border font-medium`}
>
<statusConfig.icon className="w-3 h-3 mr-1" />
{request.status}
</Badge>
{request.department && (
<Badge variant="secondary" className="bg-gray-100 text-gray-700">
{request.department}
</Badge>
)}
</div>
<h4 className="text-xl font-bold text-gray-900 mb-2 line-clamp-1">
{request.title}
</h4>
<p className="text-gray-600 line-clamp-2 leading-relaxed">
{request.description}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
{/* SLA Progress for Open Requests */}
{type === 'open' && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">SLA Progress</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-sm font-semibold ${slaConfig.textColor}`}>
{request.slaRemaining} remaining
</span>
{slaConfig.urgency === 'critical' && (
<Badge variant="destructive" className="animate-pulse text-xs">
URGENT
</Badge>
)}
</div>
</div>
<Progress
value={request.slaProgress}
className="h-3 bg-gray-200"
/>
</div>
)}
{/* Status Info */}
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2">
{type === 'open' ? (
<AlertCircle className="w-4 h-4 text-blue-500" />
) : (
<CheckCircle className="w-4 h-4 text-green-500" />
)}
<span className="text-sm text-gray-700 font-medium">
{type === 'open' ? request.approvalStep : request.reason}
</span>
</div>
</div>
{/* Participants & Metadata */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-slate-700 text-white text-sm font-semibold">
{request.initiator.avatar}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium text-gray-900">{request.initiator.name}</p>
<p className="text-xs text-gray-500">Initiator</p>
</div>
</div>
{type === 'open' && request.currentApprover && (
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 ring-2 ring-yellow-200 shadow-sm">
<AvatarFallback className="bg-yellow-500 text-white text-sm font-semibold">
{request.currentApprover.avatar}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium text-gray-900">{request.currentApprover.name}</p>
<p className="text-xs text-gray-500">Current Approver</p>
</div>
</div>
)}
</div>
<div className="text-right">
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
Created {request.createdAt}
</span>
<span>Due {request.dueDate}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Empty State */}
{filteredAndSortedRequests.length === 0 && (
<Card className="shadow-lg border-0">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<FileText className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">No requests found</h3>
<p className="text-gray-600 text-center max-w-md">
{searchTerm || activeFiltersCount > 0
? 'Try adjusting your filters or search terms to see more results.'
: `No ${type} requests available at the moment.`
}
</p>
{activeFiltersCount > 0 && (
<Button
variant="outline"
className="mt-4"
onClick={clearFilters}
>
Clear all filters
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}

817
components/WorkNoteView.tsx Normal file
View File

@ -0,0 +1,817 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Avatar, AvatarFallback } from './ui/avatar';
import { Badge } from './ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { ScrollArea } from './ui/scroll-area';
import { Separator } from './ui/separator';
import { Textarea } from './ui/textarea';
import {
ArrowLeft,
Send,
Smile,
Paperclip,
Users,
FileText,
Download,
Eye,
MoreHorizontal,
MessageSquare,
Clock,
CheckCircle,
AlertCircle,
Search,
Hash,
AtSign,
Phone,
Video,
Settings,
Pin,
Share,
Archive,
Plus,
Filter,
Calendar,
Zap,
Activity,
Bell,
Star,
Flag,
X
} from 'lucide-react';
interface Message {
id: string;
user: {
name: string;
avatar: string;
role: string;
};
content: string;
timestamp: string;
mentions?: string[];
isSystem?: boolean;
attachments?: {
name: string;
url: string;
type: string;
}[];
reactions?: {
emoji: string;
users: string[];
}[];
isHighPriority?: boolean;
}
interface Participant {
name: string;
avatar: string;
role: string;
status: 'online' | 'away' | 'offline';
email: string;
lastSeen?: string;
permissions: string[];
}
interface WorkNoteViewProps {
requestId: string;
onBack?: () => void;
}
// Get request data from the same source as RequestDetail
const REQUEST_DATABASE = {
'RE-REQ-001': {
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
department: 'Marketing',
priority: 'high',
status: 'pending'
},
'RE-REQ-002': {
id: 'RE-REQ-002',
title: 'IT Equipment Purchase',
department: 'IT',
priority: 'medium',
status: 'in-review'
}
};
// Static data to prevent re-renders
const MOCK_PARTICIPANTS: Participant[] = [
{
name: 'Sarah Chen',
avatar: 'SC',
role: 'Initiator',
status: 'online',
email: 'sarah.chen@royalenfield.com',
permissions: ['read', 'write', 'mention']
},
{
name: 'Mike Johnson',
avatar: 'MJ',
role: 'Team Lead',
status: 'online',
email: 'mike.johnson@royalenfield.com',
permissions: ['read', 'write', 'mention', 'approve']
},
{
name: 'Lisa Wong',
avatar: 'LW',
role: 'Finance Manager',
status: 'away',
email: 'lisa.wong@royalenfield.com',
lastSeen: '5 minutes ago',
permissions: ['read', 'write', 'mention', 'approve']
},
{
name: 'Anna Smith',
avatar: 'AS',
role: 'Spectator',
status: 'online',
email: 'anna.smith@royalenfield.com',
permissions: ['read', 'write', 'mention']
},
{
name: 'John Doe',
avatar: 'JD',
role: 'Spectator',
status: 'offline',
email: 'john.doe@royalenfield.com',
lastSeen: '2 hours ago',
permissions: ['read']
},
{
name: 'Emily Davis',
avatar: 'ED',
role: 'Creative Director',
status: 'online',
email: 'emily.davis@royalenfield.com',
permissions: ['read', 'write', 'mention']
}
];
const MOCK_DOCUMENTS = [
{
name: 'Q4_Marketing_Strategy.pdf',
size: '2.4 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:32',
type: 'PDF',
url: '#'
},
{
name: 'Budget_Breakdown.xlsx',
size: '1.1 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:35',
type: 'Excel',
url: '#'
},
{
name: 'Previous_Campaign_ROI.pdf',
size: '856 KB',
uploadedBy: 'Anna Smith',
uploadedAt: '2024-10-06 09:15',
type: 'PDF',
url: '#'
},
{
name: 'Competitor_Analysis.pptx',
size: '3.2 MB',
uploadedBy: 'Emily Davis',
uploadedAt: '2024-10-06 15:22',
type: 'PowerPoint',
url: '#'
}
];
const INITIAL_MESSAGES: Message[] = [
{
id: '1',
user: { name: 'Sarah Chen', avatar: 'SC', role: 'Initiator' },
content: 'Hi everyone! I\'ve submitted the marketing campaign budget request for Q4. Please review the attached documents and let me know if you need any additional information.',
timestamp: '2024-10-05 14:30',
isSystem: false,
reactions: [
{ emoji: '👍', users: ['Mike Johnson', 'Anna Smith'] },
{ emoji: '📋', users: ['Lisa Wong'] }
]
},
{
id: '2',
user: { name: 'System', avatar: 'SY', role: 'System' },
content: 'Request RE-REQ-001 has been created and assigned to Mike Johnson for initial review.',
timestamp: '2024-10-05 14:31',
isSystem: true
},
{
id: '3',
user: { name: 'Anna Smith', avatar: 'AS', role: 'Spectator' },
content: 'I\'ve added the previous campaign ROI data to help with the decision. The numbers show a 285% ROI from our last similar campaign. @Mike Johnson @Lisa Wong please check it out when you have a moment.',
timestamp: '2024-10-06 09:15',
mentions: ['Mike Johnson', 'Lisa Wong'],
attachments: [
{ name: 'Previous_Campaign_ROI.pdf', url: '#', type: 'pdf' }
]
},
{
id: '4',
user: { name: 'Mike Johnson', avatar: 'MJ', role: 'Team Lead' },
content: 'Thanks @Anna Smith! The historical data is very helpful. After reviewing the strategy document and budget breakdown, I believe this campaign is well-planned and has strong potential. I\'m approving this and forwarding to Finance for final review.',
timestamp: '2024-10-06 10:30',
mentions: ['Anna Smith'],
reactions: [
{ emoji: '✅', users: ['Sarah Chen', 'Anna Smith'] }
]
},
{
id: '5',
user: { name: 'System', avatar: 'SY', role: 'System' },
content: 'Request approved by Mike Johnson and forwarded to Lisa Wong for finance review.',
timestamp: '2024-10-06 10:31',
isSystem: true
},
{
id: '6',
user: { name: 'Emily Davis', avatar: 'ED', role: 'Creative Director' },
content: 'Great work on the strategy @Sarah Chen! I\'ve also added our competitor analysis to provide more context. The creative assets timeline looks achievable.',
timestamp: '2024-10-06 15:22',
mentions: ['Sarah Chen'],
attachments: [
{ name: 'Competitor_Analysis.pptx', url: '#', type: 'pptx' }
]
},
{
id: '7',
user: { name: 'Lisa Wong', avatar: 'LW', role: 'Finance Manager' },
content: 'I\'m currently reviewing the budget allocation and comparing it with Q3 spending. @Sarah Chen can you clarify the expected timeline for the LinkedIn ads campaign? Also, do we have approval from legal for the content strategy?',
timestamp: '2024-10-07 14:20',
mentions: ['Sarah Chen'],
isHighPriority: true
},
{
id: '8',
user: { name: 'Sarah Chen', avatar: 'SC', role: 'Initiator' },
content: 'Hi @Lisa Wong! For the LinkedIn campaign:\n\n• Launch: November 1st\n• Duration: 8 weeks\n• Budget distribution: 40% first 4 weeks, 60% last 4 weeks\n\nRegarding legal approval - I\'ll coordinate with the legal team this week. The content strategy follows our established brand guidelines.',
timestamp: '2024-10-07 15:45',
mentions: ['Lisa Wong']
}
];
// Utility functions
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'away': return 'bg-yellow-500';
case 'offline': return 'bg-gray-400';
default: return 'bg-gray-400';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'online': return 'Online';
case 'away': return 'Away';
case 'offline': return 'Offline';
default: return 'Unknown';
}
};
const formatMessage = (content: string) => {
// Enhanced mention highlighting with better regex
return content
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
.replace(/\n/g, '<br />');
};
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'pdf': return '📄';
case 'excel': case 'xlsx': return '📊';
case 'powerpoint': case 'pptx': return '📊';
case 'word': case 'docx': return '📝';
case 'image': case 'png': case 'jpg': case 'jpeg': return '🖼️';
default: return '📎';
}
};
export function WorkNoteView({ requestId, onBack }: WorkNoteViewProps) {
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [activeTab, setActiveTab] = useState('chat');
const [searchTerm, setSearchTerm] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [showSidebar, setShowSidebar] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Get request info
const requestInfo = useMemo(() => {
const data = REQUEST_DATABASE[requestId as keyof typeof REQUEST_DATABASE];
return data || {
id: requestId,
title: 'Unknown Request',
department: 'Unknown',
priority: 'medium',
status: 'pending'
};
}, [requestId]);
const onlineParticipants = MOCK_PARTICIPANTS.filter(p => p.status === 'online');
const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = () => {
if (message.trim()) {
const newMessage: Message = {
id: Date.now().toString(),
user: { name: 'You', avatar: 'YO', role: 'Current User' },
content: message,
timestamp: new Date().toLocaleString(),
mentions: extractMentions(message),
isHighPriority: message.includes('!important') || message.includes('urgent')
};
setMessages(prev => [...prev, newMessage]);
setMessage('');
}
};
const extractMentions = (text: string): string[] => {
const mentionRegex = /@([\w\s]+)(?=\s|$|[.,!?])/g;
const mentions = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
mentions.push(match[1].trim());
}
return mentions;
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const addReaction = (messageId: string, emoji: string) => {
setMessages(prev => prev.map(msg => {
if (msg.id === messageId) {
const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) {
if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You');
if (existingReaction.users.length === 0) {
return { ...msg, reactions: reactions.filter(r => r.emoji !== emoji) };
}
} else {
existingReaction.users.push('You');
}
} else {
reactions.push({ emoji, users: ['You'] });
}
return { ...msg, reactions };
}
return msg;
}));
};
return (
<div className="h-screen max-h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0">
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2 sm:gap-4 min-w-0 flex-1">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shrink-0">
<MessageSquare className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div>
<div className="min-w-0 flex-1">
<h1 className="text-lg sm:text-2xl font-bold text-gray-900">Work Notes</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-gray-600 text-sm sm:text-base truncate">{requestInfo.title}</p>
<Badge variant="outline" className="text-xs shrink-0">
{requestId}
</Badge>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<div className="flex -space-x-2">
{onlineParticipants.slice(0, 3).map((participant, index) => (
<Avatar key={index} className="h-8 w-8 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-blue-500 text-white text-xs font-semibold">
{participant.avatar}
</AvatarFallback>
</Avatar>
))}
{onlineParticipants.length > 3 && (
<div className="h-8 w-8 rounded-full bg-gray-100 ring-2 ring-white flex items-center justify-center text-xs font-medium text-gray-600">
+{onlineParticipants.length - 3}
</div>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowSidebar(true)}
className="lg:hidden"
>
<Users className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowSidebar(!showSidebar)}
className="lg:hidden"
>
<Users className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1 flex overflow-hidden relative">
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
{/* Tab Navigation */}
<div className="bg-white border-b border-gray-200 px-2 sm:px-3 lg:px-6">
<TabsList className="grid w-full max-w-full sm:max-w-md grid-cols-3 bg-gray-100 h-10">
<TabsTrigger value="chat" className="flex items-center gap-1 sm:gap-2 text-xs sm:text-sm px-2">
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Messages</span>
<span className="xs:hidden">Chat</span>
</TabsTrigger>
<TabsTrigger value="files" className="flex items-center gap-1 sm:gap-2 text-xs sm:text-sm px-2">
<FileText className="w-3 h-3 sm:w-4 sm:h-4" />
<span>Files</span>
</TabsTrigger>
<TabsTrigger value="activity" className="flex items-center gap-1 sm:gap-2 text-xs sm:text-sm px-2">
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Activity</span>
<span className="xs:hidden">Act</span>
</TabsTrigger>
</TabsList>
</div>
{/* Chat Tab */}
<TabsContent value="chat" className="flex-1 flex flex-col m-0">
{/* Search Bar */}
<div className="bg-white border-b border-gray-200 px-2 sm:px-3 lg:px-6 py-2 sm:py-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search messages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-gray-50 border-gray-200 h-9 sm:h-10"
/>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto px-2 sm:px-3 lg:px-6 py-2 sm:py-4">
<div className="space-y-3 sm:space-y-6 max-w-full">
{filteredMessages.map((msg) => (
<div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : ''}`}>
{!msg.isSystem && (
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${
msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Current User' ? 'bg-blue-500' :
msg.user.role === 'System' ? 'bg-gray-500' :
'bg-slate-600'
}`}>
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
<div className={`flex-1 min-w-0 ${msg.isSystem ? 'text-center max-w-xs sm:max-w-md mx-auto' : ''}`}>
{msg.isSystem ? (
<div className="inline-flex items-center gap-2 sm:gap-3 px-3 sm:px-4 py-1.5 sm:py-2 bg-gray-100 rounded-full">
<Activity className="w-3 h-3 sm:w-4 sm:h-4 text-gray-500 flex-shrink-0" />
<span className="text-xs sm:text-sm text-gray-700">{msg.content}</span>
<span className="text-xs text-gray-500 hidden sm:inline">{msg.timestamp}</span>
</div>
) : (
<div>
{/* Message Header */}
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2 flex-wrap">
<span className="font-semibold text-gray-900 text-sm sm:text-base truncate">{msg.user.name}</span>
<Badge variant="outline" className="text-xs flex-shrink-0">
{msg.user.role}
</Badge>
<span className="text-xs text-gray-500 flex items-center gap-1 flex-shrink-0">
<Clock className="w-3 h-3" />
{msg.timestamp}
</span>
{msg.isHighPriority && (
<Badge variant="destructive" className="text-xs flex-shrink-0">
<Flag className="w-3 h-3 mr-1" />
Priority
</Badge>
)}
</div>
{/* Message Content */}
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-4 shadow-sm">
<div
className="text-gray-800 leading-relaxed text-sm sm:text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/>
{/* Attachments */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-100">
<div className="space-y-2">
{msg.attachments.map((attachment, index) => (
<div key={index} className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-lg">
<span className="text-lg sm:text-xl flex-shrink-0">{getFileIcon(attachment.type)}</span>
<span className="text-xs sm:text-sm font-medium text-gray-700 flex-1 truncate">
{attachment.name}
</span>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 flex-shrink-0">
<Download className="w-3 h-3 sm:w-4 sm:h-4" />
</Button>
</div>
))}
</div>
</div>
)}
{/* Reactions */}
{msg.reactions && msg.reactions.length > 0 && (
<div className="flex items-center gap-1 sm:gap-2 mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-100 flex-wrap">
{msg.reactions.map((reaction, index) => (
<button
key={index}
onClick={() => addReaction(msg.id, reaction.emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${
reaction.users.includes('You')
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<span>{reaction.emoji}</span>
<span className="text-xs font-medium">{reaction.users.length}</span>
</button>
))}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 sm:h-7 sm:w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
>
<Plus className="w-2 h-2 sm:w-3 sm:h-3" />
</Button>
</div>
)}
</div>
</div>
)}
</div>
</div>
))}
{isTyping && (
<div className="flex gap-2 sm:gap-4">
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0">
<AvatarFallback className="bg-gray-400 text-white">
<div className="flex gap-0.5 sm:gap-1">
<div className="w-1 h-1 bg-white rounded-full animate-bounce"></div>
<div className="w-1 h-1 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-1 h-1 bg-white rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</AvatarFallback>
</Avatar>
<div className="flex items-center text-xs sm:text-sm text-gray-500 bg-gray-100 px-3 sm:px-4 py-2 rounded-lg">
Someone is typing...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Message Input */}
<div className="bg-white border-t border-gray-200 p-2 sm:p-3 lg:p-6">
<div className="max-w-full">
<div className="flex flex-col gap-2 sm:gap-4">
<div className="flex-1">
<Textarea
placeholder="Type your message... Use @username to mention someone"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
className="min-h-[50px] sm:min-h-[60px] resize-none border-gray-200 focus:ring-blue-500 focus:border-blue-500 w-full text-sm"
rows={2}
/>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mt-2 gap-2">
<div className="flex items-center gap-1 sm:gap-2 order-2 sm:order-1">
<Button variant="ghost" size="sm" className="text-gray-500 h-7 w-7 sm:h-8 sm:w-8 p-0">
<Paperclip className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-500 h-7 w-7 sm:h-8 sm:w-8 p-0">
<Smile className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-500 h-7 w-7 sm:h-8 sm:w-8 p-0 hidden sm:flex">
<AtSign className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-500 h-7 w-7 sm:h-8 sm:w-8 p-0 hidden sm:flex">
<Hash className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2 order-1 sm:order-2">
<span className="text-xs text-gray-500 hidden sm:inline">
{message.length}/2000
</span>
<Button
onClick={handleSendMessage}
disabled={!message.trim()}
className="bg-blue-600 hover:bg-blue-700 min-w-0 h-8 sm:h-9"
size="sm"
>
<Send className="h-3 w-3 sm:h-4 sm:w-4 sm:mr-2" />
<span className="hidden sm:inline ml-1">Send</span>
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
{/* Files Tab */}
<TabsContent value="files" className="flex-1 p-2 sm:p-3 lg:p-6 m-0">
<div className="max-w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-4 sm:mb-6 gap-3">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Shared Files</h3>
<Button className="gap-2 text-sm h-9">
<Plus className="w-4 h-4" />
<span className="hidden xs:inline">Upload File</span>
<span className="xs:hidden">Upload</span>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{MOCK_DOCUMENTS.map((doc, index) => (
<Card key={index} className="hover:shadow-lg transition-shadow">
<CardContent className="p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-lg sm:text-2xl">{getFileIcon(doc.type)}</span>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 truncate text-sm sm:text-base">{doc.name}</h4>
<p className="text-xs sm:text-sm text-gray-600 mt-1">
{doc.size} {doc.type}
</p>
<p className="text-xs text-gray-500 mt-1">
by {doc.uploadedBy} {doc.uploadedAt}
</p>
</div>
</div>
<div className="flex items-center gap-2 mt-3 sm:mt-4">
<Button variant="outline" size="sm" className="flex-1 text-xs sm:text-sm h-8 sm:h-9">
<Eye className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
View
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs sm:text-sm h-8 sm:h-9">
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
Download
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</TabsContent>
{/* Activity Tab */}
<TabsContent value="activity" className="flex-1 p-2 sm:p-3 lg:p-6 m-0">
<div className="max-w-full">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-4 sm:mb-6">Recent Activity</h3>
<div className="space-y-3 sm:space-y-4">
{messages.filter(msg => msg.isSystem).map((msg) => (
<div key={msg.id} className="flex items-start gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg">
<div className="w-7 h-7 sm:w-8 sm:h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<Activity className="w-3 h-3 sm:w-4 sm:h-4 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-gray-900 text-sm sm:text-base">{msg.content}</p>
<p className="text-xs sm:text-sm text-gray-500 mt-1">{msg.timestamp}</p>
</div>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* Mobile Sidebar Overlay */}
{showSidebar && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setShowSidebar(false)}
/>
)}
{/* Participants Sidebar */}
<div className={`
w-72 sm:w-80 bg-white border-l border-gray-200 flex flex-col
lg:relative lg:translate-x-0 lg:shadow-none
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
`}>
<div className="p-4 sm:p-6 border-b border-gray-200">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSidebar(false)}
className="lg:hidden h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3 sm:space-y-4">
{MOCK_PARTICIPANTS.map((participant, index) => (
<div key={index} className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">
<AvatarFallback className={`text-white font-semibold text-sm ${
participant.role === 'Initiator' ? 'bg-green-600' : 'bg-slate-600'
}`}>
{participant.avatar}
</AvatarFallback>
</Avatar>
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white ${getStatusColor(participant.status)}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate text-sm sm:text-base">{participant.name}</p>
<div className="flex items-center gap-2">
<p className="text-xs text-gray-500">{participant.role}</p>
<span className="text-xs text-gray-400"></span>
<p className="text-xs text-gray-500">{getStatusText(participant.status)}</p>
</div>
{participant.lastSeen && participant.status === 'offline' && (
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="p-4 sm:p-6">
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Users className="h-4 w-4" />
Add Participant
</Button>
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Bell className="h-4 w-4" />
Manage Notifications
</Button>
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
<Archive className="h-4 w-4" />
Archive Chat
</Button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}

View File

@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { User, Eye, AtSign, Plus } from 'lucide-react';
import { toast } from 'sonner@2.0.3';
interface AddUserModalProps {
isOpen: boolean;
onClose: () => void;
type: 'approver' | 'spectator';
requestId: string;
requestTitle: string;
}
export function AddUserModal({ isOpen, onClose, type, requestId, requestTitle }: AddUserModalProps) {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
toast.error('Email Required', {
description: 'Please enter an email address.',
});
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
toast.error('Invalid Email', {
description: 'Please enter a valid email address.',
});
return;
}
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success(`${type === 'approver' ? 'Approver' : 'Spectator'} Added`, {
description: `${email} has been added as ${type === 'approver' ? 'an approver' : 'a spectator'} to "${requestTitle}".`,
duration: 5000,
});
setEmail('');
onClose();
} catch (error) {
toast.error('Failed to Add User', {
description: 'Something went wrong. Please try again.',
});
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setEmail('');
onClose();
};
const Icon = type === 'approver' ? User : Eye;
const title = type === 'approver' ? 'Add Approver' : 'Add Spectator';
const description = type === 'approver'
? 'Add a new approver to this request. They will be notified and can approve or reject the request.'
: 'Add a spectator to this request. They will receive notifications but cannot approve or reject.';
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="w-5 h-5 text-re-green" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<AtSign className="w-4 h-4 text-gray-500" />
Email Address
</Label>
<Input
id="email"
type="email"
placeholder="user@example.com (use @ to add user)"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-input-background border-border focus:ring-re-green focus:border-re-green"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
Use the @ sign to mention and add a user to this request.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || !email.trim()}
className="bg-re-green hover:bg-re-green/90 text-white"
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add {type === 'approver' ? 'Approver' : 'Spectator'}
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label';
import { CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import { Badge } from '../ui/badge';
interface ApprovalActionModalProps {
isOpen: boolean;
onClose: () => void;
action: 'approve' | 'reject';
requestId: string;
requestTitle: string;
onSubmit: (action: 'approve' | 'reject', comment: string) => void;
}
export function ApprovalActionModal({
isOpen,
onClose,
action,
requestId,
requestTitle,
onSubmit
}: ApprovalActionModalProps) {
const [comment, setComment] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async () => {
if (!comment.trim() || comment.length > 500) {
return;
}
setIsSubmitting(true);
try {
await onSubmit(action, comment.trim());
setComment('');
onClose();
} catch (error) {
console.error('Error submitting approval action:', error);
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setComment('');
onClose();
};
const getActionConfig = () => {
if (action === 'approve') {
return {
title: 'Approve Request',
description: 'Please provide your approval comments and remarks',
icon: CheckCircle,
iconColor: 'text-green-600',
buttonColor: 'bg-green-600 hover:bg-green-700',
badgeColor: 'bg-green-100 text-green-800 border-green-200',
placeholder: 'Enter your approval comments and any conditions or notes...'
};
} else {
return {
title: 'Reject Request',
description: 'Please provide detailed reasons for rejection',
icon: XCircle,
iconColor: 'text-red-600',
buttonColor: 'bg-red-600 hover:bg-red-700',
badgeColor: 'bg-red-100 text-red-800 border-red-200',
placeholder: 'Enter detailed reasons for rejection and any suggestions for improvement...'
};
}
};
const config = getActionConfig();
const IconComponent = config.icon;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg ${action === 'approve' ? 'bg-green-100' : 'bg-red-100'}`}>
<IconComponent className={`w-6 h-6 ${config.iconColor}`} />
</div>
<div className="flex-1">
<DialogTitle className="text-xl">{config.title}</DialogTitle>
<DialogDescription className="mt-1">
{config.description}
</DialogDescription>
</div>
</div>
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Request ID:</span>
<Badge variant="outline" className="font-mono">
{requestId}
</Badge>
</div>
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className={config.badgeColor} variant="outline">
<IconComponent className="w-3 h-3 mr-1" />
{action === 'approve' ? 'APPROVE' : 'REJECT'}
</Badge>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900">
Comments & Remarks *
</Label>
<Textarea
id="comment"
placeholder={config.placeholder}
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, 500))}
className="min-h-[120px] resize-none"
rows={6}
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Comments are required and will be visible to all participants
</div>
<span>{comment.length}/500</span>
</div>
</div>
{action === 'reject' && (
<div className="p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-orange-600 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-orange-800">Rejection Guidelines</p>
<p className="text-orange-700 mt-1">
Please provide specific, actionable feedback to help the initiator improve their request.
</p>
</div>
</div>
</div>
)}
{action === 'approve' && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-green-600 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-green-800">Approval Confirmation</p>
<p className="text-green-700 mt-1">
This request will be forwarded to the next approver or completed if this is the final step.
</p>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!comment.trim() || isSubmitting || comment.length > 500}
className={config.buttonColor}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
Processing...
</>
) : (
<>
<IconComponent className="w-4 h-4 mr-2" />
{action === 'approve' ? 'Approve Request' : 'Reject Request'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,310 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { Upload, FileText, X, CheckCircle, AlertCircle } from 'lucide-react';
import { toast } from 'sonner@2.0.3';
interface DealerDocumentModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
proposalDocument: File | null;
costBreakup: File | null;
timeline: File | null;
otherDocuments: File[];
dealerComments: string;
}) => Promise<void>;
dealerName: string;
activityName: string;
}
export function DealerDocumentModal({
isOpen,
onClose,
onSubmit,
dealerName,
activityName
}: DealerDocumentModalProps) {
const [proposalDocument, setProposalDocument] = useState<File | null>(null);
const [costBreakup, setCostBreakup] = useState<File | null>(null);
const [timeline, setTimeline] = useState<File | null>(null);
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
const [dealerComments, setDealerComments] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleFileUpload = (field: 'proposal' | 'cost' | 'timeline', file: File | null) => {
if (field === 'proposal') setProposalDocument(file);
if (field === 'cost') setCostBreakup(file);
if (field === 'timeline') setTimeline(file);
};
const handleMultipleFileUpload = (files: FileList | null) => {
if (files) {
const fileArray = Array.from(files);
setOtherDocuments(prev => [...prev, ...fileArray]);
}
};
const removeOtherDocument = (index: number) => {
setOtherDocuments(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
// Validation
if (!proposalDocument) {
toast.error('Proposal document is required');
return;
}
if (!costBreakup) {
toast.error('Cost breakup document is required');
return;
}
if (!timeline) {
toast.error('Timeline document is required');
return;
}
if (!dealerComments.trim()) {
toast.error('Please add dealer comments');
return;
}
setIsSubmitting(true);
try {
await onSubmit({
proposalDocument,
costBreakup,
timeline,
otherDocuments,
dealerComments
});
// Reset form
setProposalDocument(null);
setCostBreakup(null);
setTimeline(null);
setOtherDocuments([]);
setDealerComments('');
onClose();
} catch (error) {
console.error('Error submitting dealer documents:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
Dealer Document Upload
</DialogTitle>
<DialogDescription className="text-base">
<div className="space-y-1 mt-2">
<p><strong>Dealer:</strong> {dealerName}</p>
<p><strong>Activity:</strong> {activityName}</p>
<p className="text-sm text-gray-600 mt-2">
Please upload all required documents and provide detailed comments about this claim request.
</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Required Documents Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Required Documents</h3>
<Badge variant="destructive" className="text-xs">All Required</Badge>
</div>
{/* Proposal Document */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
Proposal Document *
{proposalDocument && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<p className="text-sm text-gray-600 mb-2">
Detailed proposal with activity details and requested information
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
type="file"
accept=".pdf,.doc,.docx"
onChange={(e) => handleFileUpload('proposal', e.target.files?.[0] || null)}
className="hidden"
id="proposalDoc"
/>
<label htmlFor="proposalDoc" className="cursor-pointer flex flex-col items-center gap-2">
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">Click to upload proposal (PDF, DOC, DOCX)</span>
{proposalDocument && (
<Badge variant="secondary" className="mt-2 flex items-center gap-1">
<FileText className="w-3 h-3" />
{proposalDocument.name}
</Badge>
)}
</label>
</div>
</div>
{/* Cost Breakup */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
Cost Breakup *
{costBreakup && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<p className="text-sm text-gray-600 mb-2">
Detailed cost analysis and breakdown
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
type="file"
accept=".pdf,.xlsx,.xls,.csv"
onChange={(e) => handleFileUpload('cost', e.target.files?.[0] || null)}
className="hidden"
id="costDoc"
/>
<label htmlFor="costDoc" className="cursor-pointer flex flex-col items-center gap-2">
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">Click to upload cost breakup (Excel, PDF, CSV)</span>
{costBreakup && (
<Badge variant="secondary" className="mt-2 flex items-center gap-1">
<FileText className="w-3 h-3" />
{costBreakup.name}
</Badge>
)}
</label>
</div>
</div>
{/* Timeline for Closure */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
Timeline for Closure *
{timeline && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<p className="text-sm text-gray-600 mb-2">
Project timeline and milestone details
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
type="file"
accept=".pdf,.doc,.docx,.xlsx,.xls"
onChange={(e) => handleFileUpload('timeline', e.target.files?.[0] || null)}
className="hidden"
id="timelineDoc"
/>
<label htmlFor="timelineDoc" className="cursor-pointer flex flex-col items-center gap-2">
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">Click to upload timeline (PDF, DOC, Excel)</span>
{timeline && (
<Badge variant="secondary" className="mt-2 flex items-center gap-1">
<FileText className="w-3 h-3" />
{timeline.name}
</Badge>
)}
</label>
</div>
</div>
</div>
{/* Optional Documents */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Other Supporting Documents</h3>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div>
<Label className="text-base font-semibold">Additional Documents</Label>
<p className="text-sm text-gray-600 mb-2">
Any other supporting documents (invoices, receipts, photos, etc.)
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
type="file"
multiple
onChange={(e) => handleMultipleFileUpload(e.target.files)}
className="hidden"
id="otherDocs"
/>
<label htmlFor="otherDocs" className="cursor-pointer flex flex-col items-center gap-2">
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">Click to upload additional documents (multiple files allowed)</span>
</label>
</div>
{otherDocuments.length > 0 && (
<div className="mt-3 space-y-2">
{otherDocuments.map((file, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded border">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-600" />
<span className="text-sm">{file.name}</span>
<span className="text-xs text-gray-500">({(file.size / 1024).toFixed(1)} KB)</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeOtherDocument(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Dealer Comments */}
<div className="space-y-2">
<Label htmlFor="dealerComments" className="text-base font-semibold flex items-center gap-2">
Dealer Comments / Details *
{dealerComments.trim() && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<Textarea
id="dealerComments"
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
value={dealerComments}
onChange={(e) => setDealerComments(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">
{dealerComments.length} characters
</p>
</div>
{/* Validation Alert */}
{(!proposalDocument || !costBreakup || !timeline || !dealerComments.trim()) && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>Please ensure all required documents are uploaded and dealer comments are provided before submitting.</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !proposalDocument || !costBreakup || !timeline || !dealerComments.trim()}
className="bg-[--re-green] hover:bg-[--re-green-dark]"
>
{isSubmitting ? 'Submitting...' : 'Submit Documents'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Input } from '../ui/input';
import { Textarea } from '../ui/textarea';
import { Badge } from '../ui/badge';
import { CheckCircle, AlertCircle, DollarSign, FileText } from 'lucide-react';
import { toast } from 'sonner@2.0.3';
interface InitiatorVerificationModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
approvedAmount: string;
verificationComments: string;
}) => Promise<void>;
activityName: string;
requestedAmount?: string;
documents?: any[];
}
export function InitiatorVerificationModal({
isOpen,
onClose,
onSubmit,
activityName,
requestedAmount = 'TBD',
documents = []
}: InitiatorVerificationModalProps) {
const [approvedAmount, setApprovedAmount] = useState(requestedAmount === 'TBD' ? '' : requestedAmount.replace(/[₹,]/g, ''));
const [verificationComments, setVerificationComments] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const formatCurrency = (value: string) => {
// Remove non-numeric characters
const numericValue = value.replace(/[^0-9]/g, '');
if (!numericValue) return '';
// Format with commas
const number = parseInt(numericValue);
return `${number.toLocaleString('en-IN')}`;
};
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^0-9]/g, '');
setApprovedAmount(value);
};
const handleSubmit = async () => {
// Validation
if (!approvedAmount || approvedAmount === '0') {
toast.error('Please enter a valid approved amount');
return;
}
if (!verificationComments.trim()) {
toast.error('Please add verification comments');
return;
}
setIsSubmitting(true);
try {
await onSubmit({
approvedAmount: formatCurrency(approvedAmount),
verificationComments
});
onClose();
} catch (error) {
console.error('Error submitting verification:', error);
} finally {
setIsSubmitting(false);
}
};
const isAmountModified = requestedAmount !== 'TBD' &&
formatCurrency(approvedAmount) !== requestedAmount;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
<CheckCircle className="w-6 h-6 text-[--re-green]" />
Initiator Verification & Approval
</DialogTitle>
<DialogDescription className="text-base">
<div className="space-y-1 mt-2">
<p><strong>Activity:</strong> {activityName}</p>
<p className="text-sm text-gray-600 mt-2">
Review the dealer's completion documents and approve the final amount for E-invoice generation.
</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Completion Documents Review */}
<div className="space-y-3">
<Label className="text-base font-semibold">Dealer Completion Documents</Label>
{documents.length > 0 ? (
<div className="border rounded-lg divide-y">
{documents.slice(0, 5).map((doc, index) => (
<div key={index} className="p-3 flex items-center gap-3 hover:bg-gray-50">
<FileText className="w-5 h-5 text-gray-600" />
<div className="flex-1">
<p className="text-sm font-medium">{doc.name}</p>
<p className="text-xs text-gray-500">{doc.size} Uploaded by {doc.uploadedBy}</p>
</div>
<Button variant="outline" size="sm">View</Button>
</div>
))}
</div>
) : (
<div className="border border-dashed rounded-lg p-4 text-center text-gray-500 text-sm">
No completion documents uploaded yet
</div>
)}
</div>
{/* Amount Approval Section */}
<div className="space-y-4 bg-blue-50 border-2 border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<Label className="text-base font-semibold">Amount Approval</Label>
{isAmountModified && (
<Badge variant="destructive" className="text-xs">Amount Modified</Badge>
)}
</div>
{requestedAmount !== 'TBD' && (
<div className="bg-white rounded-lg p-3 border">
<Label className="text-xs text-gray-600 uppercase tracking-wider">Original Requested Amount</Label>
<p className="text-lg font-semibold text-gray-900 mt-1">{requestedAmount}</p>
</div>
)}
<div>
<Label htmlFor="approvedAmount" className="text-base font-semibold flex items-center gap-2">
Final Approved Amount *
{approvedAmount && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<p className="text-sm text-gray-600 mb-2">
Enter the final amount to be approved for E-invoice generation
</p>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500" />
<Input
id="approvedAmount"
type="text"
placeholder="Enter amount (e.g., 245000)"
value={approvedAmount}
onChange={handleAmountChange}
className="pl-10 h-12 text-lg"
/>
</div>
{approvedAmount && (
<p className="text-sm text-gray-600 mt-2">
Formatted: <span className="font-semibold">{formatCurrency(approvedAmount)}</span>
</p>
)}
</div>
{isAmountModified && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-semibold">Amount has been modified</p>
<p className="text-xs">Please provide justification in the verification comments below.</p>
</div>
</div>
)}
</div>
{/* Verification Comments */}
<div className="space-y-2">
<Label htmlFor="verificationComments" className="text-base font-semibold flex items-center gap-2">
Verification Comments *
{verificationComments.trim() && <CheckCircle className="w-4 h-4 text-green-600" />}
</Label>
<Textarea
id="verificationComments"
placeholder="Provide your verification comments, document review notes, and justification for any amount modifications..."
value={verificationComments}
onChange={(e) => setVerificationComments(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">
{verificationComments.length} characters
</p>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-1">Next Steps After Approval</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>E-Invoice will be automatically generated based on the approved amount</li>
<li>Request will be forwarded to Finance for credit note issuance</li>
<li>Dealer will be notified of the approval</li>
</ul>
</div>
</div>
{/* Validation Alert */}
{(!approvedAmount || !verificationComments.trim()) && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>Please enter the approved amount and provide verification comments before submitting.</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !approvedAmount || !verificationComments.trim()}
className="bg-[--re-green] hover:bg-[--re-green-dark]"
>
{isSubmitting ? 'Approving...' : 'Approve & Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,599 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Badge } from '../ui/badge';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Progress } from '../ui/progress';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Upload,
X,
User,
Clock,
FileText,
Check,
Users
} from 'lucide-react';
import { format } from 'date-fns';
interface NewRequestModalProps {
open: boolean;
onClose: () => void;
onSubmit?: (requestData: any) => void;
}
export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProps) {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({
title: '',
description: '',
priority: '',
slaEndDate: undefined as Date | undefined,
approvers: [] as any[],
workflowType: 'sequential',
spectators: [] as any[],
documents: [] as File[]
});
const totalSteps = 5;
// Mock users for selection
const availableUsers = [
{ id: '1', name: 'Mike Johnson', role: 'Team Lead', avatar: 'MJ' },
{ id: '2', name: 'Lisa Wong', role: 'Finance Manager', avatar: 'LW' },
{ id: '3', name: 'David Kumar', role: 'Department Head', avatar: 'DK' },
{ id: '4', name: 'Anna Smith', role: 'Marketing Coordinator', avatar: 'AS' },
{ id: '5', name: 'John Doe', role: 'Budget Analyst', avatar: 'JD' }
];
const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const addApprover = (user: any) => {
if (!formData.approvers.find(a => a.id === user.id)) {
updateFormData('approvers', [...formData.approvers, user]);
}
};
const removeApprover = (userId: string) => {
updateFormData('approvers', formData.approvers.filter(a => a.id !== userId));
};
const addSpectator = (user: any) => {
if (!formData.spectators.find(s => s.id === user.id)) {
updateFormData('spectators', [...formData.spectators, user]);
}
};
const removeSpectator = (userId: string) => {
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
updateFormData('documents', [...formData.documents, ...files]);
};
const removeDocument = (index: number) => {
const newDocs = formData.documents.filter((_, i) => i !== index);
updateFormData('documents', newDocs);
};
const isStepValid = () => {
switch (currentStep) {
case 1:
return formData.title && formData.description && formData.priority && formData.slaEndDate;
case 2:
return formData.approvers.length > 0;
case 3:
return true; // Spectators are optional
case 4:
return true; // Documents are optional
case 5:
return true; // Review step
default:
return false;
}
};
const nextStep = () => {
if (currentStep < totalSteps && isStepValid()) {
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleSubmit = () => {
if (isStepValid()) {
onSubmit?.(formData);
onClose();
// Reset form
setCurrentStep(1);
setFormData({
title: '',
description: '',
priority: '',
slaEndDate: undefined,
approvers: [],
workflowType: 'sequential',
spectators: [],
documents: []
});
}
};
const renderStepContent = () => {
switch (currentStep) {
case 1:
return (
<div className="space-y-4">
<div>
<Label htmlFor="title">Request Title *</Label>
<Input
id="title"
placeholder="Enter a descriptive title for your request"
value={formData.title}
onChange={(e) => updateFormData('title', e.target.value)}
/>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
placeholder="Provide detailed information about your request"
className="min-h-[120px]"
value={formData.description}
onChange={(e) => updateFormData('description', e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label>Priority *</Label>
<Select value={formData.priority} onValueChange={(value) => updateFormData('priority', value)}>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="high">High Priority</SelectItem>
<SelectItem value="medium">Medium Priority</SelectItem>
<SelectItem value="low">Low Priority</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>SLA End Date *</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left">
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={formData.slaEndDate}
onSelect={(date) => updateFormData('slaEndDate', date)}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
);
case 2:
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Workflow Type</Label>
<div className="flex items-center space-x-2">
<Label htmlFor="workflow-sequential" className="text-sm">Sequential</Label>
<Switch
id="workflow-sequential"
checked={formData.workflowType === 'parallel'}
onCheckedChange={(checked) => updateFormData('workflowType', checked ? 'parallel' : 'sequential')}
/>
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
</div>
</div>
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
{formData.workflowType === 'sequential'
? 'Approvers will review the request one after another in the order you specify.'
: 'All approvers will review the request simultaneously.'
}
</div>
<div>
<Label>Add Approvers *</Label>
<Select onValueChange={(userId) => {
const user = availableUsers.find(u => u.id === userId);
if (user) addApprover(user);
}}>
<SelectTrigger>
<SelectValue placeholder="Select users to add as approvers" />
</SelectTrigger>
<SelectContent>
{availableUsers
.filter(user => !formData.approvers.find(a => a.id === user.id))
.map(user => (
<SelectItem key={user.id} value={user.id}>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback className="bg-re-green text-white text-xs">
{user.avatar}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.role}</p>
</div>
</div>
</SelectItem>
))
}
</SelectContent>
</Select>
</div>
{formData.approvers.length > 0 && (
<div className="space-y-2">
<Label>Selected Approvers ({formData.approvers.length})</Label>
<div className="space-y-2">
{formData.approvers.map((approver, index) => (
<div key={approver.id} className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{formData.workflowType === 'sequential' && (
<Badge variant="outline" className="text-xs">
{index + 1}
</Badge>
)}
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-re-green text-white text-xs">
{approver.avatar}
</AvatarFallback>
</Avatar>
</div>
<div>
<p className="font-medium text-sm">{approver.name}</p>
<p className="text-xs text-muted-foreground">{approver.role}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeApprover(approver.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
case 3:
return (
<div className="space-y-4">
<div>
<Label>Add Spectators (Optional)</Label>
<p className="text-sm text-muted-foreground mb-2">
Spectators can view the request and participate in work notes but cannot approve or edit.
</p>
<Select onValueChange={(userId) => {
const user = availableUsers.find(u => u.id === userId);
if (user) addSpectator(user);
}}>
<SelectTrigger>
<SelectValue placeholder="Select users to add as spectators" />
</SelectTrigger>
<SelectContent>
{availableUsers
.filter(user =>
!formData.spectators.find(s => s.id === user.id) &&
!formData.approvers.find(a => a.id === user.id)
)
.map(user => (
<SelectItem key={user.id} value={user.id}>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarFallback className="bg-re-light-green text-white text-xs">
{user.avatar}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.role}</p>
</div>
</div>
</SelectItem>
))
}
</SelectContent>
</Select>
</div>
{formData.spectators.length > 0 && (
<div className="space-y-2">
<Label>Selected Spectators ({formData.spectators.length})</Label>
<div className="space-y-2">
{formData.spectators.map((spectator) => (
<div key={spectator.id} className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-re-light-green text-white text-xs">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-sm">{spectator.name}</p>
<p className="text-xs text-muted-foreground">{spectator.role}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeSpectator(spectator.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
case 4:
return (
<div className="space-y-4">
<div>
<Label>Upload Documents (Optional)</Label>
<p className="text-sm text-muted-foreground mb-2">
Attach supporting documents for your request. Maximum 10MB per file.
</p>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-2">
Drag and drop files here, or click to browse
</p>
<input
type="file"
multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
onChange={handleFileUpload}
className="hidden"
id="file-upload"
/>
<Label htmlFor="file-upload" className="cursor-pointer">
<Button variant="outline" size="sm" type="button">
Browse Files
</Button>
</Label>
</div>
</div>
{formData.documents.length > 0 && (
<div className="space-y-2">
<Label>Uploaded Documents ({formData.documents.length})</Label>
<div className="space-y-2">
{formData.documents.map((file, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-muted/30 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-6 w-6 text-muted-foreground" />
<div>
<p className="font-medium text-sm">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeDocument(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
);
case 5:
return (
<div className="space-y-6">
<div>
<h3 className="font-semibold mb-2">Review Your Request</h3>
<p className="text-sm text-muted-foreground">
Please review all details before submitting your request.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Basic Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label>Title</Label>
<p className="text-sm">{formData.title}</p>
</div>
<div>
<Label>Description</Label>
<p className="text-sm text-muted-foreground">{formData.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Priority</Label>
<Badge className="mt-1">
{formData.priority}
</Badge>
</div>
<div>
<Label>SLA End Date</Label>
<p className="text-sm">
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Not set'}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Workflow & Participants
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label>Workflow Type</Label>
<Badge variant="outline" className="mt-1">
{formData.workflowType}
</Badge>
</div>
<div>
<Label>Approvers ({formData.approvers.length})</Label>
<div className="flex flex-wrap gap-2 mt-1">
{formData.approvers.map((approver, index) => (
<Badge key={approver.id} variant="secondary">
{formData.workflowType === 'sequential' && `${index + 1}. `}
{approver.name}
</Badge>
))}
</div>
</div>
{formData.spectators.length > 0 && (
<div>
<Label>Spectators ({formData.spectators.length})</Label>
<div className="flex flex-wrap gap-2 mt-1">
{formData.spectators.map((spectator) => (
<Badge key={spectator.id} variant="outline">
{spectator.name}
</Badge>
))}
</div>
</div>
)}
{formData.documents.length > 0 && (
<div>
<Label>Documents ({formData.documents.length})</Label>
<div className="flex flex-wrap gap-2 mt-1">
{formData.documents.map((doc, index) => (
<Badge key={index} variant="outline">
{doc.name}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Request</DialogTitle>
<DialogDescription>
Step {currentStep} of {totalSteps}
</DialogDescription>
</DialogHeader>
{/* Progress Bar */}
<div className="space-y-2">
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>Basics</span>
<span>Workflow</span>
<span>Participants</span>
<span>Documents</span>
<span>Review</span>
</div>
</div>
{/* Step Content */}
<div className="py-4">
{renderStepContent()}
</div>
{/* Navigation Buttons */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={prevStep}
disabled={currentStep === 1}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
{currentStep === totalSteps ? (
<Button
onClick={handleSubmit}
disabled={!isStepValid()}
className="bg-re-green hover:bg-re-green/90"
>
<Check className="h-4 w-4 mr-2" />
Submit Request
</Button>
) : (
<Button
onClick={nextStep}
disabled={!isStepValid()}
className="bg-re-green hover:bg-re-green/90"
>
Next
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,260 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import {
FileText,
Receipt,
Package,
TrendingUp,
Users,
ArrowRight,
Clock,
CheckCircle,
Target,
X,
Sparkles,
Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
interface TemplateSelectionModalProps {
open: boolean;
onClose: () => void;
onSelectTemplate: (templateId: string) => void;
}
const AVAILABLE_TEMPLATES = [
{
id: 'claim-management',
name: 'Claim Management',
description: 'End-to-end dealer claim processing workflow with automatic IO generation and budget blocking',
category: 'Dealer Operations',
icon: Receipt,
color: 'from-blue-500 to-indigo-600',
estimatedTime: '5-7 days',
steps: 7,
features: [
'Automatic IO confirmation',
'Budget blocking',
'Document verification',
'E-invoice generation',
'Credit note issuance'
]
},
{
id: 'vendor-payment',
name: 'Vendor Payment',
description: 'Streamlined vendor payment approval with PO validation and financial controls',
category: 'Finance',
icon: Package,
color: 'from-green-500 to-emerald-600',
estimatedTime: '3-5 days',
steps: 5,
features: [
'PO matching',
'Invoice verification',
'Multi-level approvals',
'Payment scheduling'
]
}
];
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const handleSelect = (templateId: string) => {
setSelectedTemplate(templateId);
};
const handleContinue = () => {
if (selectedTemplate) {
onSelectTemplate(selectedTemplate);
onClose();
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
>
{/* Accessibility - Hidden Title and Description */}
<DialogTitle className="sr-only">Select a Template</DialogTitle>
<DialogDescription className="sr-only">
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
</DialogDescription>
{/* Custom Close button */}
<button
onClick={onClose}
className="absolute top-6 right-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 flex items-center justify-center transition-all hover:scale-110"
>
<X className="w-5 h-5 text-gray-600" />
</button>
{/* Full Screen Content Container */}
<div className="h-full overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
{/* Header Section */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 max-w-3xl"
>
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Sparkles className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
Choose Your Template
</h1>
<p className="text-lg text-gray-600">
Select from pre-configured templates with predefined workflows and approval chains for faster processing.
</p>
</motion.div>
{/* Template Cards Grid */}
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{AVAILABLE_TEMPLATES.map((template, index) => {
const Icon = template.icon;
const isSelected = selectedTemplate === template.id;
return (
<motion.div
key={template.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
>
<Card
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
onClick={() => handleSelect(template.id)}
>
<CardHeader className="space-y-4 pb-4">
<div className="flex items-start justify-between">
<div className={`w-14 h-14 rounded-xl bg-gradient-to-br ${template.color} flex items-center justify-center shadow-md`}>
<Icon className="w-7 h-7 text-white" />
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center shadow-md">
<Check className="w-5 h-5 text-white" />
</div>
</motion.div>
)}
</div>
<div className="text-left">
<CardTitle className="text-xl mb-2">{template.name}</CardTitle>
<CardDescription className="text-sm leading-relaxed">
{template.description}
</CardDescription>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{template.category}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5">
<Target className="w-3.5 h-3.5" />
<span>{template.steps} steps</span>
</div>
</div>
<div className="space-y-2 pt-2">
<p className="text-xs text-gray-500 font-semibold">Key Features:</p>
<div className="space-y-1.5">
{template.features.slice(0, 3).map((feature, idx) => (
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
<CheckCircle className="w-3 h-3 text-green-600 flex-shrink-0" />
<span>{feature}</span>
</div>
))}
{template.features.length > 3 && (
<p className="text-xs text-blue-600 italic pl-5">
+{template.features.length - 3} more features
</p>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
{/* Action Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
>
<Button
variant="outline"
onClick={onClose}
size="lg"
className="px-8"
>
Cancel
</Button>
<Button
onClick={handleContinue}
disabled={!selectedTemplate}
size="lg"
className={`gap-2 px-8 ${
selectedTemplate
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400'
}`}
>
Continue with Template
<ArrowRight className="w-4 h-4" />
</Button>
</motion.div>
{/* Selected template indicator */}
<AnimatePresence>
{selectedTemplate && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mt-6 text-center"
>
<p className="text-sm text-gray-600">
Selected: <span className="font-semibold text-blue-600">
{AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.name}
</span>
</p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,362 @@
import React, { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area';
import {
Send,
Smile,
Paperclip,
Users,
FileText,
Download,
Eye,
MoreHorizontal
} from 'lucide-react';
interface Message {
id: string;
user: {
name: string;
avatar: string;
role: string;
};
content: string;
timestamp: string;
mentions?: string[];
isSystem?: boolean;
}
interface WorkNoteModalProps {
open: boolean;
onClose: () => void;
requestId: string;
}
export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps) {
const [message, setMessage] = useState('');
const [isTyping, setIsTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const participants = [
{ name: 'Sarah Chen', avatar: 'SC', role: 'Initiator', status: 'online' },
{ name: 'Mike Johnson', avatar: 'MJ', role: 'Team Lead', status: 'online' },
{ name: 'Lisa Wong', avatar: 'LW', role: 'Finance Manager', status: 'away' },
{ name: 'Anna Smith', avatar: 'AS', role: 'Spectator', status: 'offline' },
{ name: 'John Doe', avatar: 'JD', role: 'Spectator', status: 'online' }
];
const documents = [
{
name: 'Q4_Marketing_Strategy.pdf',
size: '2.4 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:32'
},
{
name: 'Budget_Breakdown.xlsx',
size: '1.1 MB',
uploadedBy: 'Sarah Chen',
uploadedAt: '2024-10-05 14:35'
},
{
name: 'Previous_Campaign_ROI.pdf',
size: '856 KB',
uploadedBy: 'Anna Smith',
uploadedAt: '2024-10-06 09:15'
}
];
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
user: { name: 'Sarah Chen', avatar: 'SC', role: 'Initiator' },
content: 'Hi everyone! I\'ve submitted the marketing campaign budget request. Please review the attached documents.',
timestamp: '2024-10-05 14:30',
isSystem: false
},
{
id: '2',
user: { name: 'System', avatar: 'SY', role: 'System' },
content: 'Request RE-REQ-001 has been created and assigned to Mike Johnson for initial review.',
timestamp: '2024-10-05 14:31',
isSystem: true
},
{
id: '3',
user: { name: 'Anna Smith', avatar: 'AS', role: 'Spectator' },
content: 'I\'ve added the previous campaign ROI data to help with the decision. @Mike Johnson @Lisa Wong please check it out.',
timestamp: '2024-10-06 09:15',
mentions: ['Mike Johnson', 'Lisa Wong']
},
{
id: '4',
user: { name: 'Mike Johnson', avatar: 'MJ', role: 'Team Lead' },
content: 'Thanks @Anna Smith! The numbers look good. I\'m approving this and forwarding to Finance.',
timestamp: '2024-10-06 10:30',
mentions: ['Anna Smith']
},
{
id: '5',
user: { name: 'System', avatar: 'SY', role: 'System' },
content: 'Request approved by Mike Johnson and forwarded to Lisa Wong for finance review.',
timestamp: '2024-10-06 10:31',
isSystem: true
},
{
id: '6',
user: { name: 'Lisa Wong', avatar: 'LW', role: 'Finance Manager' },
content: 'I\'m reviewing the budget allocation. @Sarah Chen can you clarify the expected timeline for the LinkedIn ads campaign?',
timestamp: '2024-10-07 14:20',
mentions: ['Sarah Chen']
}
]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSendMessage = () => {
if (message.trim()) {
const newMessage: Message = {
id: Date.now().toString(),
user: { name: 'You', avatar: 'YO', role: 'Current User' },
content: message,
timestamp: new Date().toLocaleString(),
mentions: extractMentions(message)
};
setMessages([...messages, newMessage]);
setMessage('');
}
};
const extractMentions = (text: string): string[] => {
const mentionRegex = /@(\w+\s?\w+)/g;
const mentions = [];
let match;
while ((match = mentionRegex.exec(text)) !== null) {
mentions.push(match[1]);
}
return mentions;
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500';
case 'away': return 'bg-yellow-500';
case 'offline': return 'bg-gray-400';
default: return 'bg-gray-400';
}
};
const formatMessage = (content: string) => {
// Simple mention highlighting
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Work Notes - {requestId}</DialogTitle>
<DialogDescription>
Collaborate with all request participants
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex gap-4">
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
<Tabs defaultValue="chat" className="flex-1 flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 border rounded-lg">
<div className="space-y-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
{!msg.isSystem && (
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className={`text-white text-xs ${
msg.user.role === 'Initiator' ? 'bg-re-green' :
msg.user.role === 'Current User' ? 'bg-blue-500' :
'bg-re-light-green'
}`}>
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
{msg.isSystem ? (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
{msg.content}
<span className="text-xs">{msg.timestamp}</span>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{msg.user.name}</span>
<Badge variant="outline" className="text-xs">
{msg.user.role}
</Badge>
<span className="text-xs text-muted-foreground">
{msg.timestamp}
</span>
</div>
<div
className="text-sm bg-muted/30 p-3 rounded-lg"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/>
</>
)}
</div>
</div>
))}
{isTyping && (
<div className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-gray-400 text-white text-xs">
...
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-1">
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-muted-foreground rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
Someone is typing...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
{/* Message Input */}
<div className="mt-4 flex gap-2">
<div className="flex-1 relative">
<Input
placeholder="Type your message... Use @username to mention someone"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
className="pr-20"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex gap-1">
<Button variant="ghost" size="sm">
<Smile className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Paperclip className="h-4 w-4" />
</Button>
</div>
</div>
<Button onClick={handleSendMessage} disabled={!message.trim()}>
<Send className="h-4 w-4" />
</Button>
</div>
</TabsContent>
<TabsContent value="media" className="flex-1">
<div className="p-4 border rounded-lg h-full">
<h4 className="font-medium mb-4">Documents ({documents.length})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{documents.map((doc, index) => (
<div key={index} className="border rounded-lg p-4 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3">
<FileText className="h-8 w-8 text-muted-foreground flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{doc.name}</p>
<p className="text-sm text-muted-foreground">
{doc.size} by {doc.uploadedBy}
</p>
<p className="text-xs text-muted-foreground">
{doc.uploadedAt}
</p>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* Participants Sidebar */}
<div className="w-72 border-l pl-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium">Participants</h4>
<Badge variant="outline">{participants.length}</Badge>
</div>
<div className="space-y-3">
{participants.map((participant, index) => (
<div key={index} className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarFallback className={`text-white text-xs ${
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
}`}>
{participant.avatar}
</AvatarFallback>
</Avatar>
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-background ${getStatusColor(participant.status)}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{participant.name}</p>
<p className="text-xs text-muted-foreground">{participant.role}</p>
</div>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="mt-6">
<h4 className="font-medium mb-3">Quick Actions</h4>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full justify-start">
<Users className="h-4 w-4 mr-2" />
Add Participant
</Button>
<Button variant="outline" size="sm" className="w-full justify-start">
<Paperclip className="h-4 w-4 mr-2" />
Upload File
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion@1.2.3";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog@1.1.6";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

66
components/ui/alert.tsx Normal file
View File

@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio@1.1.2";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

56
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar@1.1.3";
import { cn } from "./utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentProps<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentProps<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentProps<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

46
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

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

58
components/ui/button.tsx Normal file
View File

@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

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

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

241
components/ui/carousel.tsx Normal file
View File

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

353
components/ui/chart.tsx Normal file
View File

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

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox@1.1.4";
import { CheckIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible@1.1.3";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

177
components/ui/command.tsx Normal file
View File

@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk@1.1.1";
import { SearchIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu@2.2.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

140
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog@1.1.6";
import { XIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
));
DialogHeader.displayName = "DialogHeader";
const DialogFooter = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
));
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

132
components/ui/drawer.tsx Normal file
View File

@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul@1.1.2";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu@2.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

168
components/ui/form.tsx Normal file
View File

@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { Slot } from "@radix-ui/react-slot@1.1.2";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form@7.55.0";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card@1.1.6";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp@1.4.2";
import { MinusIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label@2.1.2";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

276
components/ui/menubar.tsx Normal file
View File

@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar@1.1.6";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu@1.2.5";
import { cva } from "class-variance-authority@0.7.1";
import { ChevronDownIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react@0.487.0";
import { cn } from "./utils";
import { Button, buttonVariants } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

48
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover@1.1.6";
import { cn } from "./utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress@1.1.2";
import { cn } from "./utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group@1.2.3";
import { CircleIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react@0.487.0";
import * as ResizablePrimitive from "react-resizable-panels@2.1.7";
import { cn } from "./utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area@1.2.3";
import { cn } from "./utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

189
components/ui/select.tsx Normal file
View File

@ -0,0 +1,189 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select@2.1.6";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react@0.487.0";
import { cn } from "./utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator@1.1.2";
import { cn } from "./utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

139
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog@1.1.6";
import { XIcon } from "lucide-react@0.487.0";
import { cn } from "./utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

726
components/ui/sidebar.tsx Normal file
View File

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

View File

@ -0,0 +1,13 @@
import { cn } from "./utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

63
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider@1.2.3";
import { cn } from "./utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

25
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes@0.4.6";
import { Toaster as Sonner, ToasterProps } from "sonner@2.0.3";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

31
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch@1.1.3";
import { cn } from "./utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

116
components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "./utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

66
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs@1.1.3";
import { cn } from "./utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "./utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group@1.1.2";
import { type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

47
components/ui/toggle.tsx Normal file
View File

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle@1.1.2";
import { cva, type VariantProps } from "class-variance-authority@0.7.1";
import { cn } from "./utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

61
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip@1.1.8";
import { cn } from "./utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

6
components/ui/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

61
guidelines/Guidelines.md Normal file
View File

@ -0,0 +1,61 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

232
styles/globals.css Normal file
View File

@ -0,0 +1,232 @@
@custom-variant dark (&:is(.dark *));
:root {
--font-size: 16px;
--background: #f8f7f4;
--foreground: #1a1a1a;
--card: #ffffff;
--card-foreground: #1a1a1a;
--popover: #ffffff;
--popover-foreground: #1a1a1a;
--primary: #2d4a3e;
--primary-foreground: #ffffff;
--secondary: #8a9b8e;
--secondary-foreground: #ffffff;
--muted: #e8e6e1;
--muted-foreground: #6b7170;
--accent: #c9b037;
--accent-foreground: #1a1a1a;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #d1cfc7;
--input: transparent;
--input-background: #ffffff;
--switch-background: #d1cfc7;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: #2d4a3e;
--chart-1: #2d4a3e;
--chart-2: #8a9b8e;
--chart-3: #c9b037;
--chart-4: #dc2626;
--chart-5: #6b7170;
--radius: 0.625rem;
--sidebar: #1a1a1a;
--sidebar-foreground: #ffffff;
--sidebar-primary: #2d4a3e;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #2d4a3e;
--sidebar-accent-foreground: #ffffff;
--sidebar-border: #333333;
--sidebar-ring: #2d4a3e;
--re-green: #2d4a3e;
--re-gold: #c9b037;
--re-dark: #1a1a1a;
--re-light-green: #8a9b8e;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-re-green: var(--re-green);
--color-re-gold: var(--re-gold);
--color-re-dark: var(--re-dark);
--color-re-light-green: var(--re-light-green);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/**
* Base typography. This is not applied to elements which have an ancestor with a Tailwind text class.
*/
@layer base {
:where(:not(:has([class*=" text-"]), :not(:has([class^="text-"])))) {
h1 {
font-size: 1.875rem;
font-weight: var(--font-weight-medium);
line-height: 1.4;
letter-spacing: -0.025em;
}
h2 {
font-size: 1.5rem;
font-weight: var(--font-weight-medium);
line-height: 1.4;
letter-spacing: -0.025em;
}
h3 {
font-size: 1.25rem;
font-weight: var(--font-weight-medium);
line-height: 1.4;
}
h4 {
font-size: 1rem;
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
p {
font-size: 0.875rem;
font-weight: var(--font-weight-normal);
line-height: 1.6;
}
label {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: 0.875rem;
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
}
}
/* Utility classes for better spacing and layout */
@layer utilities {
.text-balance {
text-wrap: balance;
}
.min-w-0 {
min-width: 0;
}
/* Line clamp utilities for text truncation */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
}
html {
font-size: var(--font-size);
}

View File

@ -0,0 +1,183 @@
// Claim Management Database for Royal Enfield Approval Portal
// This database is exclusively for claim management requests created via ClaimManagementWizard
// Template: Claim Management (8-step workflow)
export const CLAIM_MANAGEMENT_DATABASE: any = {
'RE-REQ-2024-CM-001': {
id: 'RE-REQ-2024-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution. Activity conducted at Royal Motors Mumbai dealership.',
category: 'Dealer Operations',
subcategory: 'Claim Management',
status: 'pending',
priority: 'standard',
amount: 'TBD',
slaProgress: 35,
slaRemaining: '4 days 12 hours',
slaEndDate: 'Oct 16, 2024 5:00 PM',
currentStep: 1,
totalSteps: 8,
template: 'claim-management',
templateName: 'Claim Management',
initiator: {
name: 'Sneha Patil',
role: 'Regional Marketing Coordinator',
department: 'Marketing - West Zone',
email: 'sneha.patil@royalenfield.com',
phone: '+91 98765 43250',
avatar: 'SP'
},
department: 'Marketing - West Zone',
createdAt: 'Oct 7, 2024 9:30 AM',
updatedAt: 'Oct 7, 2024 9:30 AM',
dueDate: '2024-10-16T17:00:00Z',
conclusionRemark: '',
claimDetails: {
activityName: 'Diwali Festival Campaign 2024',
activityType: 'Marketing Activity',
activityDate: 'Oct 5, 2024',
location: 'Mumbai, Maharashtra',
dealerCode: 'RE-MH-001',
dealerName: 'Royal Motors Mumbai',
dealerEmail: 'dealer@royalmotorsmumbai.com',
dealerPhone: '+91 98765 12345',
dealerAddress: '123 Main Street, Andheri West, Mumbai, Maharashtra 400053',
requestDescription: 'Marketing campaign for Diwali festival including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution at Royal Motors Mumbai dealership.',
estimatedBudget: '₹2,45,000',
periodStart: 'Oct 1, 2024',
periodEnd: 'Oct 10, 2024'
},
approvalFlow: [
{
step: 1,
approver: 'Royal Motors Mumbai (Dealer)',
role: 'Dealer - Document Upload',
status: 'pending',
tatHours: 72,
elapsedHours: 12,
assignedAt: '2024-10-07T09:30:00Z',
comment: null,
timestamp: null,
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
},
{
step: 2,
approver: 'Sneha Patil (Initiator)',
role: 'Initiator Evaluation',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator reviews dealer documents and approves or requests modifications'
},
{
step: 3,
approver: 'System Auto-Process',
role: 'IO Confirmation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
},
{
step: 4,
approver: 'Rajesh Kumar',
role: 'Department Lead Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Department head approves and blocks budget in IO for this activity'
},
{
step: 5,
approver: 'Royal Motors Mumbai (Dealer)',
role: 'Dealer - Completion Documents',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Dealer submits activity completion documents and description'
},
{
step: 6,
approver: 'Sneha Patil (Initiator)',
role: 'Initiator Verification',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator verifies completion documents and can modify approved amount'
},
{
step: 7,
approver: 'System Auto-Process',
role: 'E-Invoice Generation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Auto-generate e-invoice based on final approved amount'
},
{
step: 8,
approver: 'Meera Patel',
role: 'Finance - Credit Note Issuance',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Finance team issues credit note to dealer'
}
],
documents: [
{ name: 'Claim_Proposal_Diwali_2024.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:35 AM' },
{ name: 'Cost_Breakup_Detailed.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:38 AM' },
{ name: 'Activity_Timeline.pdf', size: '320 KB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:40 AM' }
],
spectators: [
{ name: 'Arjun Menon', role: 'Brand Manager', avatar: 'AM' },
{ name: 'Finance Team', role: 'Budget Monitoring', avatar: 'FT' }
],
auditTrail: [
{ type: 'created', action: 'Claim Request Created', details: 'Diwali festival campaign claim initiated using Claim Management template', user: 'Sneha Patil', timestamp: 'Oct 7, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Dealer', details: 'Dealer Royal Motors Mumbai assigned for document upload', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' },
{ type: 'status_change', action: 'Workflow Started', details: 'Step 1: Dealer document upload phase initiated', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' }
],
tags: ['claim-management', 'dealer-activity', 'marketing', 'diwali-campaign', 'template']
}
};
// API Endpoints for Claim Management (to be implemented with backend)
export const CLAIM_MANAGEMENT_API_ENDPOINTS = {
CREATE_CLAIM: '/api/v1/claim-management/create',
UPDATE_CLAIM: '/api/v1/claim-management/update',
GET_CLAIM: '/api/v1/claim-management/get',
LIST_CLAIMS: '/api/v1/claim-management/list',
UPLOAD_DEALER_DOCUMENTS: '/api/v1/claim-management/dealer/upload-documents',
INITIATOR_EVALUATE: '/api/v1/claim-management/initiator/evaluate',
GENERATE_IO: '/api/v1/claim-management/system/generate-io',
DEPARTMENT_APPROVAL: '/api/v1/claim-management/department/approve',
UPLOAD_COMPLETION_DOCS: '/api/v1/claim-management/dealer/completion-documents',
INITIATOR_VERIFY: '/api/v1/claim-management/initiator/verify',
GENERATE_EINVOICE: '/api/v1/claim-management/system/generate-einvoice',
ISSUE_CREDIT_NOTE: '/api/v1/claim-management/finance/credit-note',
GET_AUDIT_TRAIL: '/api/v1/claim-management/audit-trail',
ADD_SPECTATOR: '/api/v1/claim-management/spectators/add',
REMOVE_SPECTATOR: '/api/v1/claim-management/spectators/remove'
};

View File

@ -0,0 +1,741 @@
// Custom Request Database for Royal Enfield Approval Portal
// This database is exclusively for custom requests created via NewRequestWizard
// Users define their own workflow, approvers, spectators, and tagged participants
export const CUSTOM_REQUEST_DATABASE: any = {
'RE-REQ-2024-001': {
id: 'RE-REQ-2024-001',
title: 'Himalayan 450 Launch Campaign - Digital Media Blitz',
description: 'Comprehensive digital marketing campaign for Himalayan 450 adventure motorcycle launch. Includes social media campaigns, influencer partnerships, performance marketing, content creation, and digital advertising across platforms. Target: Reach 10M adventure enthusiasts across India.\n\nEquipment Specifications:\n• 10x MacBook Pro 16-inch (M2 Pro chip)\n• 5x Professional Camera Kits (Canon EOS R5)\n• Video Editing Workstations\n• Social Media Management Tools',
category: 'Marketing & Campaigns',
subcategory: 'Digital Marketing',
status: 'pending',
priority: 'express',
amount: '₹3,75,00,000',
slaProgress: 65,
slaRemaining: '8 hours 45 minutes',
slaEndDate: 'Oct 9, 2024 5:00 PM',
currentStep: 1,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Priya Sharma',
role: 'Senior Digital Marketing Manager',
department: 'Marketing',
email: 'priya.sharma@royalenfield.com',
phone: '+91 98765 43210',
avatar: 'PS'
},
department: 'Marketing',
createdAt: 'Oct 6, 2024 10:30 AM',
updatedAt: 'Oct 7, 2024 2:15 PM',
dueDate: '2024-10-09T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Rajesh Kumar',
role: 'Marketing Director - India',
status: 'pending',
tatHours: 24,
elapsedHours: 22,
assignedAt: '2024-10-06T10:30:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Amit Desai',
role: 'VP Product Marketing',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Himalayan_450_Digital_Strategy.pdf', size: '5.2 MB', type: 'PDF', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 10:45 AM' },
{ name: 'Budget_Breakdown_Q4_2024.xlsx', size: '980 KB', type: 'Excel', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 11:15 AM' },
{ name: 'Influencer_Partnership_List.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Marketing Team', uploadedAt: 'Oct 6, 2024 2:30 PM' },
{ name: 'Creative_Campaign_Assets.zip', size: '125 MB', type: 'ZIP', uploadedBy: 'Creative Team', uploadedAt: 'Oct 6, 2024 4:15 PM' }
],
spectators: [
{ name: 'Sarah Khan', role: 'Brand Strategy Lead', avatar: 'SK' },
{ name: 'Finance Team', role: 'Budget Approval', avatar: 'FT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'New digital marketing campaign request submitted', user: 'Priya Sharma', timestamp: 'Oct 6, 2024 10:30 AM' },
{ type: 'assignment', action: 'Assigned to Rajesh Kumar', details: 'Forwarded to Marketing Director for review', user: 'System', timestamp: 'Oct 6, 2024 10:31 AM' },
{ type: 'comment', action: 'Work Note Added', details: 'Reviewed budget allocation and target metrics', user: 'Rajesh Kumar', timestamp: 'Oct 7, 2024 2:15 PM' },
{ type: 'reminder', action: 'SLA Reminder', details: 'TAT approaching - 8 hours remaining', user: 'System', timestamp: 'Oct 7, 2024 8:15 AM' }
],
tags: ['digital-marketing', 'launch-campaign', 'himalayan-450', 'high-priority']
},
'RE-REQ-2024-002': {
id: 'RE-REQ-2024-002',
title: 'New Laptop Procurement - Design Team Expansion',
description: 'Purchase of 10 high-performance laptops for the newly expanded Product Design team. Required specifications: Latest generation processor, 32GB RAM, dedicated graphics card for 3D modeling and rendering work.',
category: 'IT & Infrastructure',
subcategory: 'Hardware Procurement',
status: 'in-review',
priority: 'standard',
amount: '₹12,50,000',
slaProgress: 45,
slaRemaining: '2 days 8 hours',
slaEndDate: 'Oct 11, 2024 5:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Vikram Singh',
role: 'Head - IT Operations',
department: 'Information Technology',
email: 'vikram.singh@royalenfield.com',
phone: '+91 98765 43221',
avatar: 'VS'
},
department: 'Information Technology',
createdAt: 'Oct 5, 2024 9:15 AM',
updatedAt: 'Oct 7, 2024 3:45 PM',
dueDate: '2024-10-11T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Meera Patel',
role: 'IT Manager',
status: 'approved',
tatHours: 24,
actualHours: 18,
assignedAt: '2024-10-05T09:15:00Z',
comment: 'Technical specifications verified. Hardware meets design team requirements.',
timestamp: '2024-10-06T03:15:00Z'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'in-review',
tatHours: 48,
elapsedHours: 32,
assignedAt: '2024-10-06T03:15:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Laptop_Specifications.pdf', size: '850 KB', type: 'PDF', uploadedBy: 'Vikram Singh', uploadedAt: 'Oct 5, 2024 9:20 AM' },
{ name: 'Vendor_Quotations.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 5, 2024 11:45 AM' },
{ name: 'Team_Expansion_Plan.pdf', size: '620 KB', type: 'PDF', uploadedBy: 'Design Team', uploadedAt: 'Oct 5, 2024 2:30 PM' }
],
spectators: [
{ name: 'Design Team Lead', role: 'End Users', avatar: 'DT' },
{ name: 'Procurement Team', role: 'Vendor Management', avatar: 'PT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Laptop procurement request for design team', user: 'Vikram Singh', timestamp: 'Oct 5, 2024 9:15 AM' },
{ type: 'assignment', action: 'Assigned to Meera Patel', details: 'IT Manager to verify specifications', user: 'System', timestamp: 'Oct 5, 2024 9:16 AM' },
{ type: 'approval', action: 'Approved by Meera Patel', details: 'Technical specifications approved', user: 'Meera Patel', timestamp: 'Oct 6, 2024 3:15 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance for budget approval', user: 'System', timestamp: 'Oct 6, 2024 3:15 AM' }
],
tags: ['hardware', 'procurement', 'design-team', 'laptops']
},
'RE-REQ-2024-003': {
id: 'RE-REQ-2024-003',
title: 'Annual Service Center Expansion - Western Region',
description: 'Proposal for opening 15 new authorized service centers across tier-2 cities in Western region. Includes infrastructure setup, technician training, spare parts inventory, and marketing support. Expected to improve service accessibility by 35% in the target region.',
category: 'Operations & Expansion',
subcategory: 'Service Network',
status: 'pending',
priority: 'standard',
amount: '₹8,75,00,000',
slaProgress: 78,
slaRemaining: '1 day 4 hours',
slaEndDate: 'Oct 10, 2024 5:00 PM',
currentStep: 1,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Sanjay Reddy',
role: 'Regional Service Manager - West',
department: 'After Sales Service',
email: 'sanjay.reddy@royalenfield.com',
phone: '+91 98765 43232',
avatar: 'SR'
},
department: 'After Sales Service',
createdAt: 'Oct 3, 2024 8:45 AM',
updatedAt: 'Oct 6, 2024 5:45 PM',
dueDate: '2024-10-10T17:00:00Z',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Ramesh Kulkarni',
role: 'Head - After Sales Service',
status: 'pending',
tatHours: 72,
elapsedHours: 85,
assignedAt: '2024-10-03T08:45:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Finance Team',
role: 'Budget Allocation',
status: 'waiting',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Legal Team',
role: 'Compliance Review',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Western_Region_Expansion_Plan.pdf', size: '7.5 MB', type: 'PDF', uploadedBy: 'Sanjay Reddy', uploadedAt: 'Oct 3, 2024 9:00 AM' },
{ name: 'Service_Center_Requirements.xlsx', size: '2.8 MB', type: 'Excel', uploadedBy: 'Planning Team', uploadedAt: 'Oct 3, 2024 11:30 AM' },
{ name: 'Customer_Demand_Analysis.pptx', size: '4.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 4, 2024 2:15 PM' },
{ name: 'ROI_Projections_Service_Network.xlsx', size: '1.9 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 5, 2024 10:45 AM' }
],
spectators: [
{ name: 'Regional Managers', role: 'Service Operations', avatar: 'RM' },
{ name: 'Training Team', role: 'Technician Development', avatar: 'TT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Service center expansion proposal submitted', user: 'Sanjay Reddy', timestamp: 'Oct 3, 2024 8:45 AM' },
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to Head of After Sales Service', user: 'System', timestamp: 'Oct 3, 2024 8:46 AM' },
{ type: 'reminder', action: 'Reminder Sent', details: 'TAT breach reminder sent to approver', user: 'System', timestamp: 'Oct 6, 2024 5:45 PM' },
{ type: 'updated', action: 'Additional Documents', details: 'ROI projections added by finance team', user: 'Finance Team', timestamp: 'Oct 5, 2024 10:45 AM' }
],
tags: ['service-expansion', 'western-region', 'tier2-cities', 'overdue']
},
'RE-REQ-2024-004': {
id: 'RE-REQ-2024-004',
title: 'Employee Training Program - Advanced Motorcycle Mechanics',
description: 'Comprehensive training program for 50 service center technicians covering advanced diagnostics, electrical systems, fuel injection troubleshooting, and customer service excellence. Program duration: 3 weeks. Includes certification upon completion.',
category: 'Human Resources',
subcategory: 'Training & Development',
status: 'approved',
priority: 'standard',
amount: '₹18,50,000',
slaProgress: 100,
slaRemaining: 'Completed',
slaEndDate: 'Oct 5, 2024 5:00 PM',
currentStep: 3,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Kavita Menon',
role: 'Training Manager',
department: 'Human Resources',
email: 'kavita.menon@royalenfield.com',
phone: '+91 98765 43243',
avatar: 'KM'
},
department: 'Human Resources',
createdAt: 'Sep 28, 2024 11:00 AM',
updatedAt: 'Oct 5, 2024 4:30 PM',
dueDate: '2024-10-05T17:00:00Z',
submittedDate: '2024-09-28T11:00:00Z',
estimatedCompletion: 'Oct 5, 2024',
currentApprover: 'Completed',
approverLevel: '3 of 3',
conclusionRemark: 'All approvals completed. Training program scheduled for November 2024.',
approvalFlow: [
{
step: 1,
approver: 'Ramesh Kulkarni',
role: 'Head - After Sales Service',
status: 'approved',
tatHours: 48,
actualHours: 36,
assignedAt: '2024-09-28T11:00:00Z',
comment: 'Excellent initiative. Training content approved.',
timestamp: 'Sep 29, 2024 11:00 PM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'approved',
tatHours: 72,
actualHours: 48,
assignedAt: '2024-09-29T23:00:00Z',
comment: 'Budget approved. Cost per participant is reasonable.',
timestamp: 'Oct 1, 2024 11:00 PM'
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'approved',
tatHours: 96,
actualHours: 72,
assignedAt: '2024-10-01T23:00:00Z',
comment: 'Final approval granted. Proceed with program execution.',
timestamp: 'Oct 5, 2024 4:30 PM'
}
],
documents: [
{ name: 'Training_Curriculum.pdf', size: '3.2 MB', type: 'PDF', uploadedBy: 'Kavita Menon', uploadedAt: 'Sep 28, 2024 11:15 AM' },
{ name: 'Trainer_Profiles.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'HR Team', uploadedAt: 'Sep 28, 2024 2:45 PM' },
{ name: 'Budget_Training_Program.xlsx', size: '680 KB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Sep 29, 2024 10:30 AM' }
],
spectators: [
{ name: 'Service Center Managers', role: 'Participant Coordination', avatar: 'SC' },
{ name: 'Quality Assurance', role: 'Training Quality', avatar: 'QA' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Training program proposal submitted', user: 'Kavita Menon', timestamp: 'Sep 28, 2024 11:00 AM' },
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to After Sales Service Head', user: 'System', timestamp: 'Sep 28, 2024 11:01 AM' },
{ type: 'approval', action: 'Approved by Ramesh Kulkarni', details: 'Level 1 approval completed', user: 'Ramesh Kulkarni', timestamp: 'Sep 29, 2024 11:00 PM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Sep 29, 2024 11:01 PM' },
{ type: 'approval', action: 'Approved by Anil Kapoor', details: 'Budget approval completed', user: 'Anil Kapoor', timestamp: 'Oct 1, 2024 11:00 PM' },
{ type: 'assignment', action: 'Assigned to Deepika Sharma', details: 'Forwarded to VP for final approval', user: 'System', timestamp: 'Oct 1, 2024 11:01 PM' },
{ type: 'approval', action: 'Approved by Deepika Sharma', details: 'Final approval - Request completed', user: 'Deepika Sharma', timestamp: 'Oct 5, 2024 4:30 PM' },
{ type: 'completed', action: 'Request Completed', details: 'All approvals obtained. Training scheduled.', user: 'System', timestamp: 'Oct 5, 2024 4:31 PM' }
],
tags: ['training', 'technicians', 'approved', 'completed']
},
'RE-REQ-2024-005': {
id: 'RE-REQ-2024-005',
title: 'Showroom Renovation - Chennai Flagship Store',
description: 'Complete renovation of Chennai flagship showroom including modern interior design, interactive display zones, customer lounge upgrade, motorcycle test ride facility, and digital experience center. Project timeline: 8 weeks.',
category: 'Infrastructure',
subcategory: 'Retail & Showroom',
status: 'rejected',
priority: 'standard',
amount: '₹65,00,000',
slaProgress: 100,
slaRemaining: 'Rejected',
slaEndDate: 'Oct 4, 2024 5:00 PM',
currentStep: 2,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Arjun Nair',
role: 'Showroom Manager - South',
department: 'Retail Operations',
email: 'arjun.nair@royalenfield.com',
phone: '+91 98765 43254',
avatar: 'AN'
},
department: 'Retail Operations',
createdAt: 'Oct 1, 2024 9:30 AM',
updatedAt: 'Oct 4, 2024 3:15 PM',
dueDate: '2024-10-04T17:00:00Z',
submittedDate: '2024-10-01T09:30:00Z',
estimatedCompletion: 'N/A',
currentApprover: 'Rejected by Anil Kapoor',
approverLevel: '2 of 4',
conclusionRemark: 'Request rejected due to insufficient budget justification. Please revise with detailed ROI analysis.',
approvalFlow: [
{
step: 1,
approver: 'Suresh Iyer',
role: 'Regional Manager - South',
status: 'approved',
tatHours: 48,
actualHours: 24,
assignedAt: '2024-10-01T09:30:00Z',
comment: 'Renovation is necessary. Current showroom needs upgrade.',
timestamp: 'Oct 2, 2024 9:30 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'rejected',
tatHours: 72,
actualHours: 48,
assignedAt: '2024-10-02T09:30:00Z',
comment: 'Budget allocation not justified. Need detailed ROI analysis and comparison with alternative renovation options. Please revise and resubmit with comprehensive financial projections.',
timestamp: 'Oct 4, 2024 3:15 PM'
},
{
step: 3,
approver: 'Legal Team',
role: 'Compliance & Contracts',
status: 'cancelled',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'cancelled',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Showroom_Renovation_Plan.pdf', size: '12.5 MB', type: 'PDF', uploadedBy: 'Arjun Nair', uploadedAt: 'Oct 1, 2024 9:45 AM' },
{ name: 'Interior_Design_Mockups.zip', size: '85 MB', type: 'ZIP', uploadedBy: 'Design Team', uploadedAt: 'Oct 1, 2024 2:30 PM' },
{ name: 'Contractor_Quotations.xlsx', size: '2.1 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 2, 2024 11:15 AM' }
],
spectators: [
{ name: 'Marketing Team', role: 'Brand Experience', avatar: 'MT' },
{ name: 'Customer Experience', role: 'Feedback & Analysis', avatar: 'CX' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Showroom renovation request submitted', user: 'Arjun Nair', timestamp: 'Oct 1, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Suresh Iyer', details: 'Forwarded to Regional Manager', user: 'System', timestamp: 'Oct 1, 2024 9:31 AM' },
{ type: 'approval', action: 'Approved by Suresh Iyer', details: 'Level 1 approval completed', user: 'Suresh Iyer', timestamp: 'Oct 2, 2024 9:30 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 2, 2024 9:31 AM' },
{ type: 'rejection', action: 'Rejected by Anil Kapoor', details: 'Budget justification insufficient', user: 'Anil Kapoor', timestamp: 'Oct 4, 2024 3:15 PM' },
{ type: 'completed', action: 'Request Rejected', details: 'Workflow terminated. Requires resubmission with revisions.', user: 'System', timestamp: 'Oct 4, 2024 3:16 PM' }
],
tags: ['showroom', 'renovation', 'rejected', 'south-region']
},
'RE-REQ-2024-006': {
id: 'RE-REQ-2024-006',
title: 'Spare Parts Inventory Optimization System',
description: 'Implementation of AI-powered inventory management system for spare parts across all service centers. Features include demand forecasting, automated reordering, stock level optimization, and real-time tracking. Expected to reduce inventory costs by 20% and improve part availability.',
category: 'Technology & Innovation',
subcategory: 'Software Implementation',
status: 'pending',
priority: 'express',
amount: '₹42,00,000',
slaProgress: 35,
slaRemaining: '1 day 16 hours',
slaEndDate: 'Oct 12, 2024 5:00 PM',
currentStep: 1,
totalSteps: 4,
template: 'custom',
initiator: {
name: 'Rahul Deshmukh',
role: 'Head - Supply Chain Technology',
department: 'Supply Chain',
email: 'rahul.deshmukh@royalenfield.com',
phone: '+91 98765 43265',
avatar: 'RD'
},
department: 'Supply Chain',
createdAt: 'Oct 7, 2024 10:00 AM',
updatedAt: 'Oct 8, 2024 9:15 AM',
dueDate: '2024-10-12T17:00:00Z',
submittedDate: '2024-10-07T10:00:00Z',
estimatedCompletion: 'Oct 12, 2024',
currentApprover: 'Vikram Singh',
approverLevel: '1 of 4',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Vikram Singh',
role: 'Head - IT Operations',
status: 'pending',
tatHours: 48,
elapsedHours: 23,
assignedAt: '2024-10-07T10:00:00Z',
comment: null,
timestamp: null
},
{
step: 2,
approver: 'Supply Chain Director',
role: 'Operations Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'waiting',
tatHours: 96,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
},
{
step: 4,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'AI_Inventory_System_Proposal.pdf', size: '8.9 MB', type: 'PDF', uploadedBy: 'Rahul Deshmukh', uploadedAt: 'Oct 7, 2024 10:15 AM' },
{ name: 'Vendor_Comparison_Analysis.xlsx', size: '3.4 MB', type: 'Excel', uploadedBy: 'IT Procurement', uploadedAt: 'Oct 7, 2024 2:45 PM' },
{ name: 'Cost_Benefit_Analysis.pptx', size: '6.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 7, 2024 4:30 PM' },
{ name: 'Implementation_Timeline.pdf', size: '1.5 MB', type: 'PDF', uploadedBy: 'Project Management', uploadedAt: 'Oct 8, 2024 9:15 AM' }
],
spectators: [
{ name: 'Service Center Network', role: 'End Users', avatar: 'SN' },
{ name: 'Data Analytics Team', role: 'System Integration', avatar: 'DA' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'AI inventory system proposal submitted', user: 'Rahul Deshmukh', timestamp: 'Oct 7, 2024 10:00 AM' },
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 7, 2024 10:01 AM' },
{ type: 'updated', action: 'Documents Added', details: 'Implementation timeline document uploaded', user: 'Project Management', timestamp: 'Oct 8, 2024 9:15 AM' }
],
tags: ['technology', 'ai', 'inventory', 'supply-chain', 'high-priority']
},
'RE-REQ-2024-007': {
id: 'RE-REQ-2024-007',
title: 'Dealer Network Meeting - Q4 Business Review',
description: 'Quarterly business review meeting for all authorized dealers across India. Venue: Bangalore. Topics include Q3 performance review, Q4 targets, new model launches, marketing initiatives, service excellence programs, and dealer support policies. Expected attendance: 250 dealers.',
category: 'Events & Conferences',
subcategory: 'Dealer Meetings',
status: 'in-review',
priority: 'standard',
amount: '₹28,50,000',
slaProgress: 58,
slaRemaining: '1 day 12 hours',
slaEndDate: 'Oct 11, 2024 5:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Neha Kapoor',
role: 'Dealer Network Manager',
department: 'Sales & Distribution',
email: 'neha.kapoor@royalenfield.com',
phone: '+91 98765 43276',
avatar: 'NK'
},
department: 'Sales & Distribution',
createdAt: 'Oct 6, 2024 2:00 PM',
updatedAt: 'Oct 8, 2024 11:30 AM',
dueDate: '2024-10-11T17:00:00Z',
submittedDate: '2024-10-06T14:00:00Z',
estimatedCompletion: 'Oct 11, 2024',
currentApprover: 'Anil Kapoor',
approverLevel: '2 of 3',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Suresh Mehta',
role: 'Sales Director',
status: 'approved',
tatHours: 48,
actualHours: 36,
assignedAt: '2024-10-06T14:00:00Z',
comment: 'Dealer meeting approved. Agenda looks comprehensive.',
timestamp: 'Oct 8, 2024 2:00 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'in-review',
tatHours: 72,
elapsedHours: 33,
assignedAt: '2024-10-08T02:00:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Deepika Sharma',
role: 'VP Sales & Marketing',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Q4_Dealer_Meeting_Agenda.pdf', size: '2.8 MB', type: 'PDF', uploadedBy: 'Neha Kapoor', uploadedAt: 'Oct 6, 2024 2:15 PM' },
{ name: 'Venue_Booking_Confirmation.pdf', size: '980 KB', type: 'PDF', uploadedBy: 'Events Team', uploadedAt: 'Oct 6, 2024 4:45 PM' },
{ name: 'Event_Budget_Breakdown.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 7, 2024 10:30 AM' },
{ name: 'Dealer_Invitations_List.xlsx', size: '580 KB', type: 'Excel', uploadedBy: 'Sales Team', uploadedAt: 'Oct 7, 2024 3:15 PM' }
],
spectators: [
{ name: 'Marketing Team', role: 'Presentation Support', avatar: 'MT' },
{ name: 'Events Management', role: 'Logistics Coordination', avatar: 'EM' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Dealer meeting proposal submitted', user: 'Neha Kapoor', timestamp: 'Oct 6, 2024 2:00 PM' },
{ type: 'assignment', action: 'Assigned to Suresh Mehta', details: 'Forwarded to Sales Director', user: 'System', timestamp: 'Oct 6, 2024 2:01 PM' },
{ type: 'approval', action: 'Approved by Suresh Mehta', details: 'Sales approval completed', user: 'Suresh Mehta', timestamp: 'Oct 8, 2024 2:00 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 8, 2024 2:01 AM' },
{ type: 'updated', action: 'Documents Added', details: 'Dealer invitations list uploaded', user: 'Sales Team', timestamp: 'Oct 7, 2024 3:15 PM' }
],
tags: ['dealer-meeting', 'q4-review', 'event', 'bangalore']
},
'RE-REQ-2024-008': {
id: 'RE-REQ-2024-008',
title: 'Cybersecurity Infrastructure Upgrade',
description: 'Comprehensive upgrade of cybersecurity infrastructure including next-gen firewall, intrusion detection system, endpoint protection for 500+ devices, security information and event management (SIEM) system, and employee security awareness training. Critical for protecting customer data and business operations.',
category: 'IT & Infrastructure',
subcategory: 'Security & Compliance',
status: 'pending',
priority: 'urgent',
amount: '₹52,00,000',
slaProgress: 82,
slaRemaining: '4 hours 20 minutes',
slaEndDate: 'Oct 8, 2024 6:00 PM',
currentStep: 2,
totalSteps: 3,
template: 'custom',
initiator: {
name: 'Sameer Joshi',
role: 'Chief Information Security Officer',
department: 'Information Technology',
email: 'sameer.joshi@royalenfield.com',
phone: '+91 98765 43287',
avatar: 'SJ'
},
department: 'Information Technology',
createdAt: 'Oct 5, 2024 11:30 AM',
updatedAt: 'Oct 8, 2024 12:45 PM',
dueDate: '2024-10-08T18:00:00Z',
submittedDate: '2024-10-05T11:30:00Z',
estimatedCompletion: 'Oct 8, 2024',
currentApprover: 'Anil Kapoor',
approverLevel: '2 of 3',
conclusionRemark: '',
approvalFlow: [
{
step: 1,
approver: 'Vikram Singh',
role: 'Head - IT Operations',
status: 'approved',
tatHours: 24,
actualHours: 18,
assignedAt: '2024-10-05T11:30:00Z',
comment: 'Critical security upgrade. Approve immediately.',
timestamp: 'Oct 6, 2024 5:30 AM'
},
{
step: 2,
approver: 'Anil Kapoor',
role: 'Finance Manager',
status: 'pending',
tatHours: 48,
elapsedHours: 55,
assignedAt: '2024-10-06T05:30:00Z',
comment: null,
timestamp: null
},
{
step: 3,
approver: 'Ramesh Kulkarni',
role: 'VP Operations',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null
}
],
documents: [
{ name: 'Security_Assessment_Report.pdf', size: '15.3 MB', type: 'PDF', uploadedBy: 'Sameer Joshi', uploadedAt: 'Oct 5, 2024 11:45 AM' },
{ name: 'Vendor_Solutions_Comparison.xlsx', size: '4.8 MB', type: 'Excel', uploadedBy: 'IT Security Team', uploadedAt: 'Oct 5, 2024 3:30 PM' },
{ name: 'Implementation_Roadmap.pptx', size: '7.6 MB', type: 'PowerPoint', uploadedBy: 'Project Management', uploadedAt: 'Oct 6, 2024 10:15 AM' },
{ name: 'Risk_Analysis_Report.pdf', size: '5.9 MB', type: 'PDF', uploadedBy: 'Security Consultant', uploadedAt: 'Oct 6, 2024 4:45 PM' }
],
spectators: [
{ name: 'Legal & Compliance', role: 'Data Protection', avatar: 'LC' },
{ name: 'IT Infrastructure', role: 'System Integration', avatar: 'IT' }
],
auditTrail: [
{ type: 'created', action: 'Request Created', details: 'Cybersecurity upgrade proposal submitted', user: 'Sameer Joshi', timestamp: 'Oct 5, 2024 11:30 AM' },
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 5, 2024 11:31 AM' },
{ type: 'approval', action: 'Approved by Vikram Singh', details: 'IT approval - marked as critical', user: 'Vikram Singh', timestamp: 'Oct 6, 2024 5:30 AM' },
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 6, 2024 5:31 AM' },
{ type: 'reminder', action: 'Urgent Reminder', details: 'TAT breach warning - 4 hours remaining', user: 'System', timestamp: 'Oct 8, 2024 12:45 PM' }
],
tags: ['cybersecurity', 'urgent', 'critical', 'infrastructure', 'overdue']
}
};
// API Endpoints for Custom Requests (to be implemented with backend)
export const CUSTOM_REQUEST_API_ENDPOINTS = {
CREATE_REQUEST: '/api/v1/custom-request/create',
UPDATE_REQUEST: '/api/v1/custom-request/update',
GET_REQUEST: '/api/v1/custom-request/get',
LIST_REQUESTS: '/api/v1/custom-request/list',
ADD_APPROVER: '/api/v1/custom-request/approvers/add',
REMOVE_APPROVER: '/api/v1/custom-request/approvers/remove',
APPROVE_STEP: '/api/v1/custom-request/approve',
REJECT_STEP: '/api/v1/custom-request/reject',
ADD_SPECTATOR: '/api/v1/custom-request/spectators/add',
REMOVE_SPECTATOR: '/api/v1/custom-request/spectators/remove',
ADD_TAGGED_PARTICIPANT: '/api/v1/custom-request/tagged/add',
REMOVE_TAGGED_PARTICIPANT: '/api/v1/custom-request/tagged/remove',
UPLOAD_DOCUMENT: '/api/v1/custom-request/documents/upload',
DELETE_DOCUMENT: '/api/v1/custom-request/documents/delete',
ADD_WORK_NOTE: '/api/v1/custom-request/work-notes/add',
GET_AUDIT_TRAIL: '/api/v1/custom-request/audit-trail',
SEND_REMINDER: '/api/v1/custom-request/reminder/send',
ESCALATE_REQUEST: '/api/v1/custom-request/escalate',
MODIFY_SLA: '/api/v1/custom-request/sla/modify'
};

188
utils/dealerDatabase.ts Normal file
View File

@ -0,0 +1,188 @@
// Mock Dealer Database - In production, this would be fetched from API
export interface DealerInfo {
code: string;
name: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
region: string;
managerName: string;
}
export const DEALER_DATABASE: Record<string, DealerInfo> = {
'RE-MH-001': {
code: 'RE-MH-001',
name: 'Royal Motors Mumbai',
email: 'dealer@royalmotorsmumbai.com',
phone: '+91 98765 12345',
address: 'Shop No. 12-15, Central Avenue, Andheri West',
city: 'Mumbai',
state: 'Maharashtra',
region: 'West',
managerName: 'Rahul Deshmukh'
},
'RE-DL-002': {
code: 'RE-DL-002',
name: 'Delhi Enfield Center',
email: 'contact@delhienfield.com',
phone: '+91 98765 23456',
address: '45-48, Rajouri Garden, Main Market',
city: 'New Delhi',
state: 'Delhi',
region: 'North',
managerName: 'Vikram Singh'
},
'RE-BLR-003': {
code: 'RE-BLR-003',
name: 'Bangalore Royal Bikes',
email: 'info@bangaloreroyalbikes.com',
phone: '+91 98765 34567',
address: '123, MG Road, Near Trinity Metro',
city: 'Bangalore',
state: 'Karnataka',
region: 'South',
managerName: 'Suresh Kumar'
},
'RE-CHN-004': {
code: 'RE-CHN-004',
name: 'Chennai Enfield Hub',
email: 'chennai@enfieldhub.com',
phone: '+91 98765 45678',
address: '78-80, Anna Salai, T Nagar',
city: 'Chennai',
state: 'Tamil Nadu',
region: 'South',
managerName: 'Venkat Ramanan'
},
'RE-HYD-005': {
code: 'RE-HYD-005',
name: 'Hyderabad Royal Motorcycles',
email: 'hyderabad@royalmotorcycles.com',
phone: '+91 98765 56789',
address: '234, Banjara Hills, Road No. 12',
city: 'Hyderabad',
state: 'Telangana',
region: 'South',
managerName: 'Anil Reddy'
},
'RE-KOL-006': {
code: 'RE-KOL-006',
name: 'Kolkata Enfield Motors',
email: 'kolkata@enfieldmotors.com',
phone: '+91 98765 67890',
address: '56-58, Park Street, Near Park Hotel',
city: 'Kolkata',
state: 'West Bengal',
region: 'East',
managerName: 'Amit Chatterjee'
},
'RE-PUN-007': {
code: 'RE-PUN-007',
name: 'Pune Royal Dealership',
email: 'pune@royaldealership.com',
phone: '+91 98765 78901',
address: '345, FC Road, Deccan Gymkhana',
city: 'Pune',
state: 'Maharashtra',
region: 'West',
managerName: 'Sandeep Patil'
},
'RE-AHM-008': {
code: 'RE-AHM-008',
name: 'Ahmedabad Enfield Plaza',
email: 'ahmedabad@enfieldplaza.com',
phone: '+91 98765 89012',
address: '123, CG Road, Navrangpura',
city: 'Ahmedabad',
state: 'Gujarat',
region: 'West',
managerName: 'Kiran Patel'
},
'RE-JP-009': {
code: 'RE-JP-009',
name: 'Jaipur Royal Enfield',
email: 'jaipur@royalenfield.com',
phone: '+91 98765 90123',
address: '67, MI Road, C-Scheme',
city: 'Jaipur',
state: 'Rajasthan',
region: 'North',
managerName: 'Rajesh Sharma'
},
'RE-LKO-010': {
code: 'RE-LKO-010',
name: 'Lucknow Enfield Showroom',
email: 'lucknow@enfieldshowroom.com',
phone: '+91 98765 01234',
address: '89, Hazratganj, Near Halwasiya Crossing',
city: 'Lucknow',
state: 'Uttar Pradesh',
region: 'North',
managerName: 'Ankit Verma'
}
};
/**
* Get dealer information by dealer code
* @param dealerCode - The dealer code (e.g., 'RE-MH-001')
* @returns DealerInfo object or null if not found
*/
export function getDealerInfo(dealerCode: string): DealerInfo | null {
return DEALER_DATABASE[dealerCode] || null;
}
/**
* Get all dealers for a specific region
* @param region - Region name (North, South, East, West)
* @returns Array of DealerInfo objects
*/
export function getDealersByRegion(region: string): DealerInfo[] {
return Object.values(DEALER_DATABASE).filter(
dealer => dealer.region.toLowerCase() === region.toLowerCase()
);
}
/**
* Get all dealers for a specific state
* @param state - State name
* @returns Array of DealerInfo objects
*/
export function getDealersByState(state: string): DealerInfo[] {
return Object.values(DEALER_DATABASE).filter(
dealer => dealer.state.toLowerCase() === state.toLowerCase()
);
}
/**
* Get all dealers as an array (for dropdowns, etc.)
* @returns Array of DealerInfo objects
*/
export function getAllDealers(): DealerInfo[] {
return Object.values(DEALER_DATABASE);
}
/**
* Search dealers by name or code
* @param searchTerm - Search term
* @returns Array of matching DealerInfo objects
*/
export function searchDealers(searchTerm: string): DealerInfo[] {
const term = searchTerm.toLowerCase();
return Object.values(DEALER_DATABASE).filter(
dealer =>
dealer.name.toLowerCase().includes(term) ||
dealer.code.toLowerCase().includes(term) ||
dealer.city.toLowerCase().includes(term)
);
}
/**
* Format dealer address for display
* @param dealer - DealerInfo object
* @returns Formatted address string
*/
export function formatDealerAddress(dealer: DealerInfo): string {
return `${dealer.address}, ${dealer.city}, ${dealer.state}`;
}