mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Create configurable approval workflow templates for different batch types
This commit is contained in:
@@ -98,6 +98,10 @@ This roadmap outlines the phased development plan for WorkForce Pro, a cloud-bas
|
||||
- ✅ Event-driven processing updates
|
||||
- ✅ Email notification templates
|
||||
- ✅ Configurable notification rules
|
||||
- ✅ Configurable approval workflow templates for different batch types
|
||||
- ✅ Multi-step approval workflows with escalation rules
|
||||
- ✅ Conditional approval step skipping
|
||||
- ✅ Template management for payroll, invoices, timesheets, expenses, compliance, and purchase orders
|
||||
- 📋 Automated follow-up reminders
|
||||
|
||||
---
|
||||
|
||||
386
WORKFLOW_TEMPLATES.md
Normal file
386
WORKFLOW_TEMPLATES.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Approval Workflow Templates
|
||||
|
||||
## Overview
|
||||
|
||||
The Approval Workflow Templates system provides a flexible, configurable framework for managing multi-step approval processes across different batch types in WorkForce Pro. This system allows administrators to define, customize, and manage approval workflows that can be applied to payroll batches, invoices, timesheets, expenses, compliance documents, and purchase orders.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Template Management
|
||||
- **Create Templates**: Define reusable approval workflows for different batch types
|
||||
- **Edit Templates**: Modify existing templates including steps, approvers, and rules
|
||||
- **Duplicate Templates**: Clone templates to create variations quickly
|
||||
- **Delete Templates**: Remove unused or obsolete templates
|
||||
- **Active/Inactive Status**: Enable or disable templates without deletion
|
||||
- **Default Templates**: Set default workflows for each batch type
|
||||
|
||||
### 2. Multi-Step Approval Workflows
|
||||
Each template can contain multiple approval steps executed sequentially:
|
||||
- **Step Order**: Define the sequence of approval steps
|
||||
- **Approver Roles**: Assign specific roles responsible for each step
|
||||
- **Step Descriptions**: Add context and instructions for approvers
|
||||
- **Required Comments**: Force approvers to provide justification
|
||||
- **Skippable Steps**: Allow conditional bypassing of steps
|
||||
|
||||
### 3. Escalation Rules
|
||||
Prevent bottlenecks with automatic escalation:
|
||||
- **Time-Based Escalation**: Escalate to senior roles after specified hours
|
||||
- **Escalation Targets**: Define which role receives escalated approvals
|
||||
- **Original Approver Notification**: Keep initial approvers informed
|
||||
- **Multiple Escalation Tiers**: Chain escalations for critical processes
|
||||
|
||||
### 4. Conditional Logic
|
||||
Smart workflows that adapt to batch characteristics:
|
||||
- **Skip Conditions**: Automatically skip steps based on field values
|
||||
- **Auto-Approval Conditions**: Approve steps automatically when criteria are met
|
||||
- **Field Operators**: Support for equals, greater than, less than, contains, not equals
|
||||
- **Compound Logic**: Combine conditions with AND/OR operators
|
||||
|
||||
### 5. Batch Type Support
|
||||
Preconfigured templates for six core batch types:
|
||||
- **Payroll**: Multi-step approval with finance oversight
|
||||
- **Invoice**: Single or multi-tier approval based on amount
|
||||
- **Timesheet**: Quick approval with optional manager review
|
||||
- **Expense**: Two-step approval with finance authorization
|
||||
- **Compliance**: Rigorous approval with legal review
|
||||
- **Purchase Order**: Budget and finance approval workflows
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Models
|
||||
|
||||
#### WorkflowTemplate
|
||||
```typescript
|
||||
interface WorkflowTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
batchType: 'payroll' | 'invoice' | 'timesheet' | 'expense' | 'compliance' | 'purchase-order'
|
||||
isActive: boolean
|
||||
isDefault: boolean
|
||||
steps: ApprovalStepTemplate[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy?: string
|
||||
metadata?: {
|
||||
color?: string
|
||||
icon?: string
|
||||
tags?: string[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ApprovalStepTemplate
|
||||
```typescript
|
||||
interface ApprovalStepTemplate {
|
||||
id: string
|
||||
order: number
|
||||
name: string
|
||||
description?: string
|
||||
approverRole: string
|
||||
requiresComments: boolean
|
||||
canSkip: boolean
|
||||
skipConditions?: StepCondition[]
|
||||
autoApprovalConditions?: StepCondition[]
|
||||
escalationRules?: EscalationRule[]
|
||||
}
|
||||
```
|
||||
|
||||
#### EscalationRule
|
||||
```typescript
|
||||
interface EscalationRule {
|
||||
id: string
|
||||
hoursUntilEscalation: number
|
||||
escalateTo: string
|
||||
notifyOriginalApprover: boolean
|
||||
}
|
||||
```
|
||||
|
||||
#### StepCondition
|
||||
```typescript
|
||||
interface StepCondition {
|
||||
id: string
|
||||
field: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan' | 'contains' | 'notEquals'
|
||||
value: string | number
|
||||
logic?: 'AND' | 'OR'
|
||||
}
|
||||
```
|
||||
|
||||
### Storage
|
||||
- **IndexedDB**: All workflow templates are persisted in the browser's IndexedDB
|
||||
- **useIndexedDBState Hook**: Provides reactive state management with automatic persistence
|
||||
- **No Server Dependencies**: Fully client-side with instant updates
|
||||
|
||||
### Components
|
||||
|
||||
#### ApprovalWorkflowTemplateManager
|
||||
Main management interface with:
|
||||
- Template list view with filtering by batch type
|
||||
- Create, edit, duplicate, and delete operations
|
||||
- Active/inactive status indicators
|
||||
- Default template badges
|
||||
|
||||
#### WorkflowTemplateCard
|
||||
Displays template summary:
|
||||
- Template name and description
|
||||
- Batch type badge with color coding
|
||||
- Status indicators (active/inactive, default)
|
||||
- Step count and overview
|
||||
- Action buttons (edit, duplicate, delete)
|
||||
- Escalation rule indicators
|
||||
|
||||
#### WorkflowTemplateEditor
|
||||
Comprehensive editing interface:
|
||||
- **Template Details Section**:
|
||||
- Name and description
|
||||
- Batch type selection
|
||||
- Active/inactive toggle
|
||||
- Set as default option
|
||||
- **Steps Section**:
|
||||
- Add, remove, reorder steps
|
||||
- Step-by-step configuration
|
||||
- Expand/collapse individual steps
|
||||
- Visual step numbering
|
||||
- **Step Configuration**:
|
||||
- Step name and description
|
||||
- Approver role selection
|
||||
- Requires comments toggle
|
||||
- Can skip toggle
|
||||
- Escalation rules management
|
||||
- **Escalation Rules**:
|
||||
- Hours until escalation
|
||||
- Escalation target role
|
||||
- Original approver notification
|
||||
|
||||
### Hooks
|
||||
|
||||
#### useApprovalWorkflowTemplates
|
||||
Primary hook for template management:
|
||||
- `createTemplate()`: Create new template
|
||||
- `updateTemplate()`: Update existing template
|
||||
- `deleteTemplate()`: Remove template
|
||||
- `duplicateTemplate()`: Clone template
|
||||
- `addStep()`: Add approval step
|
||||
- `updateStep()`: Modify step configuration
|
||||
- `removeStep()`: Delete step
|
||||
- `reorderSteps()`: Change step sequence
|
||||
- `setDefaultTemplate()`: Set default for batch type
|
||||
- `getTemplatesByBatchType()`: Filter templates
|
||||
- `getDefaultTemplate()`: Get default for batch type
|
||||
- `getActiveTemplates()`: Get all active templates
|
||||
|
||||
#### useSampleWorkflowTemplates
|
||||
Initializes sample templates on first load:
|
||||
- Standard Payroll Approval (2-step with escalation)
|
||||
- Client Invoice Approval (1-step standard)
|
||||
- Large Invoice Approval (3-step for high values)
|
||||
- Timesheet Batch Approval (1-step with skip conditions)
|
||||
- Expense Claim Approval (2-step with conditional skip)
|
||||
- Compliance Document Approval (2-step rigorous)
|
||||
- Purchase Order Approval (2-step with escalation)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a New Template
|
||||
```typescript
|
||||
const { createTemplate, updateTemplate } = useApprovalWorkflowTemplates()
|
||||
|
||||
const template = createTemplate(
|
||||
'High-Value Invoice Workflow',
|
||||
'invoice',
|
||||
'Special approval process for invoices over $50,000'
|
||||
)
|
||||
|
||||
updateTemplate(template.id, {
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
steps: [
|
||||
{
|
||||
id: 'step-1',
|
||||
order: 0,
|
||||
name: 'Billing Manager Review',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: 'step-2',
|
||||
order: 1,
|
||||
name: 'CFO Approval',
|
||||
approverRole: 'CFO',
|
||||
requiresComments: true,
|
||||
canSkip: false,
|
||||
escalationRules: [{
|
||||
id: 'esc-1',
|
||||
hoursUntilEscalation: 24,
|
||||
escalateTo: 'CEO',
|
||||
notifyOriginalApprover: true
|
||||
}]
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### Adding Conditional Logic
|
||||
```typescript
|
||||
const { updateStep } = useApprovalWorkflowTemplates()
|
||||
|
||||
updateStep(templateId, stepId, {
|
||||
canSkip: true,
|
||||
skipConditions: [
|
||||
{
|
||||
id: 'cond-1',
|
||||
field: 'totalAmount',
|
||||
operator: 'lessThan',
|
||||
value: 10000
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
### Setting Default Template
|
||||
```typescript
|
||||
const { setDefaultTemplate } = useApprovalWorkflowTemplates()
|
||||
|
||||
setDefaultTemplate(templateId, 'payroll')
|
||||
```
|
||||
|
||||
## User Interface
|
||||
|
||||
### Navigation
|
||||
Access via: **Configuration → Workflow Templates**
|
||||
|
||||
### Template List View
|
||||
- Filter dropdown for batch type selection
|
||||
- Card-based layout showing:
|
||||
- Template name with badges (default, active/inactive)
|
||||
- Batch type with color coding
|
||||
- Description
|
||||
- Step count and summary
|
||||
- Creation and update dates
|
||||
- Action buttons
|
||||
|
||||
### Template Editor
|
||||
- Modal dialog with scrollable content
|
||||
- Two main sections: Template Details and Approval Steps
|
||||
- Visual step ordering with up/down arrows
|
||||
- Expandable step details
|
||||
- Inline escalation rule management
|
||||
- Save/Cancel actions
|
||||
|
||||
### Color Coding
|
||||
- **Payroll**: Blue accent
|
||||
- **Invoice**: Info blue
|
||||
- **Timesheet**: Success green
|
||||
- **Expense**: Warning amber
|
||||
- **Compliance**: Destructive red
|
||||
- **Purchase Order**: Primary purple
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Template Design
|
||||
1. **Keep It Simple**: Start with minimal steps, add complexity only when needed
|
||||
2. **Clear Naming**: Use descriptive names that indicate purpose
|
||||
3. **Document Steps**: Add descriptions to guide approvers
|
||||
4. **Set Escalations**: Prevent bottlenecks with time-based escalation
|
||||
5. **Test Thoroughly**: Validate workflows before setting as default
|
||||
|
||||
### Step Configuration
|
||||
1. **Logical Order**: Arrange steps from least to most senior authority
|
||||
2. **Required Comments**: Use for rejection scenarios and high-value approvals
|
||||
3. **Skip Wisely**: Only allow skipping for low-risk, routine approvals
|
||||
4. **Role Alignment**: Ensure approver roles match organizational structure
|
||||
|
||||
### Escalation Rules
|
||||
1. **Reasonable Timeframes**: Set escalation times based on typical response times
|
||||
2. **Clear Hierarchy**: Escalate to next level of authority
|
||||
3. **Keep Informed**: Enable notifications to maintain visibility
|
||||
4. **Multiple Tiers**: Consider multi-level escalation for critical processes
|
||||
|
||||
## Integration
|
||||
|
||||
### With Payroll Batch Processing
|
||||
The `usePayrollBatch` hook integrates workflow templates:
|
||||
```typescript
|
||||
const batch = createBatch(periodStart, periodEnd, workers)
|
||||
applyWorkflowTemplate(batch.id, templateId)
|
||||
```
|
||||
|
||||
### With Other Batch Types
|
||||
Future integration planned for:
|
||||
- Invoice generation workflows
|
||||
- Timesheet batch approval
|
||||
- Expense claim processing
|
||||
- Compliance document submission
|
||||
- Purchase order approval
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- **Parallel Approval**: Multiple approvers at same step
|
||||
- **Delegation**: Temporary approver substitution
|
||||
- **Approval History**: Detailed audit trail
|
||||
- **Custom Fields**: Dynamic field validation in conditions
|
||||
- **Email Integration**: Direct approval from email
|
||||
- **Mobile Approval**: Native mobile app integration
|
||||
- **Analytics**: Workflow performance metrics
|
||||
- **Version Control**: Template versioning and rollback
|
||||
- **Import/Export**: Share templates between instances
|
||||
- **API Access**: Programmatic template management
|
||||
|
||||
### Advanced Conditional Logic
|
||||
- **Complex Expressions**: JavaScript-based conditions
|
||||
- **Data Lookups**: Reference external data sources
|
||||
- **Time-Based Rules**: Approval requirements based on date/time
|
||||
- **User Attributes**: Conditions based on submitter characteristics
|
||||
|
||||
### Enhanced Notifications
|
||||
- **SMS Alerts**: Critical approval reminders
|
||||
- **Slack Integration**: Team channel notifications
|
||||
- **Custom Webhooks**: Integration with external systems
|
||||
- **Digest Emails**: Grouped pending approvals
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Templates Not Appearing**
|
||||
- Check browser IndexedDB is enabled
|
||||
- Clear cache and reload application
|
||||
- Verify template is set to active
|
||||
|
||||
**Steps Not Reordering**
|
||||
- Click and hold drag handle
|
||||
- Use up/down arrows as alternative
|
||||
- Refresh page if drag-drop fails
|
||||
|
||||
**Escalation Not Triggering**
|
||||
- Verify hours until escalation is reasonable
|
||||
- Check escalation target role exists
|
||||
- Ensure approver has not taken action
|
||||
|
||||
**Cannot Delete Template**
|
||||
- Check template is not set as default
|
||||
- Verify no active batches use template
|
||||
- Temporarily deactivate before deletion
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about approval workflow templates:
|
||||
1. Check this documentation
|
||||
2. Review sample templates for examples
|
||||
3. Contact system administrator
|
||||
4. Submit support ticket through platform
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.0.0 (Current)
|
||||
- Initial release
|
||||
- Six batch type support
|
||||
- Multi-step approval workflows
|
||||
- Escalation rules
|
||||
- Conditional skip logic
|
||||
- Template management UI
|
||||
- Sample templates included
|
||||
- IndexedDB persistence
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSampleData } from '@/hooks/use-sample-data'
|
||||
import { useSampleWorkflowTemplates } from '@/hooks/use-sample-workflow-templates'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useAppData } from '@/hooks/use-app-data'
|
||||
import { useAppActions } from '@/hooks/use-app-actions'
|
||||
@@ -24,7 +25,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Code } from '@phosphor-icons/react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions'
|
||||
export type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates'
|
||||
|
||||
function App() {
|
||||
const dispatch = useAppDispatch()
|
||||
@@ -38,6 +39,7 @@ function App() {
|
||||
const announce = useAnnounce()
|
||||
|
||||
useSampleData()
|
||||
useSampleWorkflowTemplates()
|
||||
useViewPreload()
|
||||
useLocaleInit()
|
||||
useSkipLink(mainContentRef, 'Skip to main content')
|
||||
|
||||
199
src/components/ApprovalWorkflowTemplateManager.tsx
Normal file
199
src/components/ApprovalWorkflowTemplateManager.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
Copy,
|
||||
FlowArrow,
|
||||
CheckCircle,
|
||||
PencilSimple,
|
||||
Funnel
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useApprovalWorkflowTemplates, type WorkflowTemplate } from '@/hooks/use-approval-workflow-templates'
|
||||
import { toast } from 'sonner'
|
||||
import { WorkflowTemplateEditor } from './workflow/WorkflowTemplateEditor'
|
||||
import { WorkflowTemplateCard } from './workflow/WorkflowTemplateCard'
|
||||
|
||||
export function ApprovalWorkflowTemplateManager() {
|
||||
const {
|
||||
templates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
getTemplatesByBatchType
|
||||
} = useApprovalWorkflowTemplates()
|
||||
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [editingTemplate, setEditingTemplate] = useState<WorkflowTemplate | null>(null)
|
||||
const [filterBatchType, setFilterBatchType] = useState<string>('all')
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
const newTemplate = createTemplate(
|
||||
'New Workflow Template',
|
||||
'payroll' as const,
|
||||
'A new workflow template'
|
||||
)
|
||||
setEditingTemplate(newTemplate)
|
||||
setShowCreateDialog(false)
|
||||
toast.success('Template created')
|
||||
}
|
||||
|
||||
const handleDeleteTemplate = (templateId: string) => {
|
||||
deleteTemplate(templateId)
|
||||
toast.success('Template deleted')
|
||||
}
|
||||
|
||||
const handleDuplicateTemplate = (templateId: string) => {
|
||||
duplicateTemplate(templateId)
|
||||
toast.success('Template duplicated')
|
||||
}
|
||||
|
||||
const filteredTemplates = filterBatchType === 'all'
|
||||
? templates
|
||||
: getTemplatesByBatchType(filterBatchType as WorkflowTemplate['batchType'])
|
||||
|
||||
const batchTypes = [
|
||||
{ value: 'payroll', label: 'Payroll' },
|
||||
{ value: 'invoice', label: 'Invoice' },
|
||||
{ value: 'timesheet', label: 'Timesheet' },
|
||||
{ value: 'expense', label: 'Expense' },
|
||||
{ value: 'compliance', label: 'Compliance' },
|
||||
{ value: 'purchase-order', label: 'Purchase Order' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-foreground">
|
||||
Approval Workflow Templates
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Configure reusable approval workflows for different batch types
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2" size={16} />
|
||||
New Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Workflow Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new template will be created with default settings. You can customize it after creation.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTemplate}>
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
<Funnel className="text-muted-foreground" size={20} />
|
||||
<Label htmlFor="batch-type-filter" className="text-sm font-medium">
|
||||
Filter by Batch Type
|
||||
</Label>
|
||||
<Select value={filterBatchType} onValueChange={setFilterBatchType}>
|
||||
<SelectTrigger id="batch-type-filter" className="w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{batchTypes.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<FlowArrow className="mx-auto mb-4 text-muted-foreground" size={48} />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
No Templates Found
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{filterBatchType === 'all'
|
||||
? 'Create your first workflow template to get started'
|
||||
: `No templates found for ${batchTypes.find(t => t.value === filterBatchType)?.label} batch type`
|
||||
}
|
||||
</p>
|
||||
{filterBatchType === 'all' && (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-2" size={16} />
|
||||
Create Template
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredTemplates.map(template => (
|
||||
<WorkflowTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onEdit={() => setEditingTemplate(template)}
|
||||
onDelete={() => handleDeleteTemplate(template.id)}
|
||||
onDuplicate={() => handleDuplicateTemplate(template.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingTemplate && (
|
||||
<WorkflowTemplateEditor
|
||||
template={editingTemplate}
|
||||
open={!!editingTemplate}
|
||||
onOpenChange={(open) => !open && setEditingTemplate(null)}
|
||||
onSave={(updatedTemplate) => {
|
||||
updateTemplate(updatedTemplate.id, updatedTemplate)
|
||||
setEditingTemplate(null)
|
||||
toast.success('Template updated')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ const DataAdminView = lazy(() => import('@/components/views/data-admin-view').th
|
||||
const TranslationDemo = lazy(() => import('@/components/TranslationDemo').then(m => ({ default: m.TranslationDemo })))
|
||||
const ProfileView = lazy(() => import('@/components/views/profile-view').then(m => ({ default: m.ProfileView })))
|
||||
const RolesPermissionsView = lazy(() => import('@/components/views/roles-permissions-view').then(m => ({ default: m.RolesPermissionsView })))
|
||||
const ApprovalWorkflowTemplateManager = lazy(() => import('@/components/ApprovalWorkflowTemplateManager').then(m => ({ default: m.ApprovalWorkflowTemplateManager })))
|
||||
|
||||
interface ViewRouterProps {
|
||||
currentView: View
|
||||
@@ -261,6 +262,9 @@ export function ViewRouter({
|
||||
case 'roles-permissions':
|
||||
return <RolesPermissionsView />
|
||||
|
||||
case 'workflow-templates':
|
||||
return <ApprovalWorkflowTemplateManager />
|
||||
|
||||
default:
|
||||
return <DashboardView metrics={metrics} />
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
UserPlus,
|
||||
CalendarBlank,
|
||||
Translate,
|
||||
Shield
|
||||
Shield,
|
||||
FlowArrow
|
||||
} from '@phosphor-icons/react'
|
||||
import { NavItem } from './NavItem'
|
||||
import { NavGroup } from './NavGroup'
|
||||
@@ -188,6 +189,14 @@ export function ConfigurationNav({ currentView, setCurrentView, expandedGroups,
|
||||
view="roles-permissions"
|
||||
permission="users.edit"
|
||||
/>
|
||||
<NavItem
|
||||
icon={<FlowArrow size={20} />}
|
||||
label="Workflow Templates"
|
||||
active={currentView === 'workflow-templates'}
|
||||
onClick={() => setCurrentView('workflow-templates')}
|
||||
view="workflow-templates"
|
||||
permission="settings.edit"
|
||||
/>
|
||||
</NavGroup>
|
||||
)
|
||||
}
|
||||
|
||||
154
src/components/workflow/WorkflowTemplateCard.tsx
Normal file
154
src/components/workflow/WorkflowTemplateCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
FlowArrow,
|
||||
PencilSimple,
|
||||
Trash,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Star
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import type { WorkflowTemplate } from '@/hooks/use-approval-workflow-templates'
|
||||
|
||||
interface WorkflowTemplateCardProps {
|
||||
template: WorkflowTemplate
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onDuplicate: () => void
|
||||
}
|
||||
|
||||
export function WorkflowTemplateCard({
|
||||
template,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate
|
||||
}: WorkflowTemplateCardProps) {
|
||||
const batchTypeLabels: Record<string, string> = {
|
||||
payroll: 'Payroll',
|
||||
invoice: 'Invoice',
|
||||
timesheet: 'Timesheet',
|
||||
expense: 'Expense',
|
||||
compliance: 'Compliance',
|
||||
'purchase-order': 'Purchase Order'
|
||||
}
|
||||
|
||||
const batchTypeColors: Record<string, string> = {
|
||||
payroll: 'bg-accent/10 text-accent-foreground border-accent/30',
|
||||
invoice: 'bg-info/10 text-info-foreground border-info/30',
|
||||
timesheet: 'bg-success/10 text-success-foreground border-success/30',
|
||||
expense: 'bg-warning/10 text-warning-foreground border-warning/30',
|
||||
compliance: 'bg-destructive/10 text-destructive-foreground border-destructive/30',
|
||||
'purchase-order': 'bg-primary/10 text-primary-foreground border-primary/30'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={!template.isActive ? 'opacity-60' : ''}>
|
||||
<CardHeader className="border-b border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{template.name}
|
||||
</h3>
|
||||
{template.isDefault && (
|
||||
<Badge variant="outline" className="bg-warning/10 text-warning-foreground border-warning/30">
|
||||
<Star size={12} className="mr-1" weight="fill" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={batchTypeColors[template.batchType] || 'bg-secondary'}
|
||||
>
|
||||
{batchTypeLabels[template.batchType]}
|
||||
</Badge>
|
||||
{template.isActive ? (
|
||||
<Badge variant="outline" className="bg-success/10 text-success-foreground border-success/30">
|
||||
<CheckCircle size={12} className="mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-muted/50 text-muted-foreground">
|
||||
<XCircle size={12} className="mr-1" />
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<PencilSimple size={16} className="mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete}>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-3">
|
||||
Approval Steps ({template.steps.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{template.steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-3 p-3 rounded-md bg-muted/30 border border-border"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-mono text-sm font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{step.name}
|
||||
</span>
|
||||
{step.canSkip && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Skippable
|
||||
</Badge>
|
||||
)}
|
||||
{step.requiresComments && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Comments Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Approver: {step.approverRole}
|
||||
{step.description && ` • ${step.description}`}
|
||||
</p>
|
||||
</div>
|
||||
{step.escalationRules && step.escalationRules.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{step.escalationRules.length} Escalation Rule{step.escalationRules.length > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 text-xs text-muted-foreground">
|
||||
<span>Created: {new Date(template.createdAt).toLocaleDateString()}</span>
|
||||
<span>Updated: {new Date(template.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
448
src/components/workflow/WorkflowTemplateEditor.tsx
Normal file
448
src/components/workflow/WorkflowTemplateEditor.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Check,
|
||||
X,
|
||||
PlusCircle,
|
||||
MinusCircle
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type { WorkflowTemplate, ApprovalStepTemplate, EscalationRule } from '@/hooks/use-approval-workflow-templates'
|
||||
|
||||
interface WorkflowTemplateEditorProps {
|
||||
template: WorkflowTemplate
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (template: WorkflowTemplate) => void
|
||||
}
|
||||
|
||||
export function WorkflowTemplateEditor({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave
|
||||
}: WorkflowTemplateEditorProps) {
|
||||
const [editedTemplate, setEditedTemplate] = useState<WorkflowTemplate>(template)
|
||||
const [editingStepId, setEditingStepId] = useState<string | null>(null)
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editedTemplate)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
const newStep: ApprovalStepTemplate = {
|
||||
id: `STEP-${Date.now()}`,
|
||||
order: editedTemplate.steps.length,
|
||||
name: 'New Step',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false
|
||||
}
|
||||
setEditedTemplate({
|
||||
...editedTemplate,
|
||||
steps: [...editedTemplate.steps, newStep]
|
||||
})
|
||||
setEditingStepId(newStep.id)
|
||||
}
|
||||
|
||||
const updateStep = (stepId: string, updates: Partial<ApprovalStepTemplate>) => {
|
||||
setEditedTemplate({
|
||||
...editedTemplate,
|
||||
steps: editedTemplate.steps.map(step =>
|
||||
step.id === stepId ? { ...step, ...updates } : step
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const removeStep = (stepId: string) => {
|
||||
setEditedTemplate({
|
||||
...editedTemplate,
|
||||
steps: editedTemplate.steps
|
||||
.filter(step => step.id !== stepId)
|
||||
.map((step, index) => ({ ...step, order: index }))
|
||||
})
|
||||
if (editingStepId === stepId) {
|
||||
setEditingStepId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const moveStep = (stepId: string, direction: 'up' | 'down') => {
|
||||
const stepIndex = editedTemplate.steps.findIndex(s => s.id === stepId)
|
||||
if (stepIndex === -1) return
|
||||
|
||||
if (direction === 'up' && stepIndex === 0) return
|
||||
if (direction === 'down' && stepIndex === editedTemplate.steps.length - 1) return
|
||||
|
||||
const newSteps = [...editedTemplate.steps]
|
||||
const targetIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1
|
||||
|
||||
const temp = newSteps[stepIndex]
|
||||
newSteps[stepIndex] = newSteps[targetIndex]
|
||||
newSteps[targetIndex] = temp
|
||||
|
||||
const reorderedSteps = newSteps.map((step, index) => ({
|
||||
...step,
|
||||
order: index
|
||||
}))
|
||||
|
||||
setEditedTemplate({
|
||||
...editedTemplate,
|
||||
steps: reorderedSteps
|
||||
})
|
||||
}
|
||||
|
||||
const addEscalationRule = (stepId: string) => {
|
||||
const newRule: EscalationRule = {
|
||||
id: `ESC-${Date.now()}`,
|
||||
hoursUntilEscalation: 24,
|
||||
escalateTo: 'Senior Manager',
|
||||
notifyOriginalApprover: true
|
||||
}
|
||||
|
||||
updateStep(stepId, {
|
||||
escalationRules: [
|
||||
...(editedTemplate.steps.find(s => s.id === stepId)?.escalationRules || []),
|
||||
newRule
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const updateEscalationRule = (stepId: string, ruleId: string, updates: Partial<EscalationRule>) => {
|
||||
const step = editedTemplate.steps.find(s => s.id === stepId)
|
||||
if (!step?.escalationRules) return
|
||||
|
||||
updateStep(stepId, {
|
||||
escalationRules: step.escalationRules.map(rule =>
|
||||
rule.id === ruleId ? { ...rule, ...updates } : rule
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const removeEscalationRule = (stepId: string, ruleId: string) => {
|
||||
const step = editedTemplate.steps.find(s => s.id === stepId)
|
||||
if (!step?.escalationRules) return
|
||||
|
||||
updateStep(stepId, {
|
||||
escalationRules: step.escalationRules.filter(rule => rule.id !== ruleId)
|
||||
})
|
||||
}
|
||||
|
||||
const approverRoles = [
|
||||
'Manager',
|
||||
'Senior Manager',
|
||||
'Director',
|
||||
'Finance Manager',
|
||||
'HR Manager',
|
||||
'Payroll Manager',
|
||||
'Compliance Officer',
|
||||
'Operations Manager',
|
||||
'CEO',
|
||||
'CFO'
|
||||
]
|
||||
|
||||
const batchTypes: Array<{ value: WorkflowTemplate['batchType']; label: string }> = [
|
||||
{ value: 'payroll', label: 'Payroll' },
|
||||
{ value: 'invoice', label: 'Invoice' },
|
||||
{ value: 'timesheet', label: 'Timesheet' },
|
||||
{ value: 'expense', label: 'Expense' },
|
||||
{ value: 'compliance', label: 'Compliance' },
|
||||
{ value: 'purchase-order', label: 'Purchase Order' }
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Workflow Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[calc(90vh-180px)] pr-4">
|
||||
<div className="space-y-6 pb-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Template Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-name">Template Name</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={editedTemplate.name}
|
||||
onChange={(e) => setEditedTemplate({ ...editedTemplate, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batch-type">Batch Type</Label>
|
||||
<Select
|
||||
value={editedTemplate.batchType}
|
||||
onValueChange={(value) => setEditedTemplate({ ...editedTemplate, batchType: value as WorkflowTemplate['batchType'] })}
|
||||
>
|
||||
<SelectTrigger id="batch-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{batchTypes.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={editedTemplate.description}
|
||||
onChange={(e) => setEditedTemplate({ ...editedTemplate, description: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="is-active"
|
||||
checked={editedTemplate.isActive}
|
||||
onCheckedChange={(checked) => setEditedTemplate({ ...editedTemplate, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="is-active" className="cursor-pointer">Active</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="is-default"
|
||||
checked={editedTemplate.isDefault}
|
||||
onCheckedChange={(checked) => setEditedTemplate({ ...editedTemplate, isDefault: checked })}
|
||||
/>
|
||||
<Label htmlFor="is-default" className="cursor-pointer">Set as Default</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Approval Steps</CardTitle>
|
||||
<Button size="sm" onClick={addStep}>
|
||||
<Plus size={16} className="mr-1" />
|
||||
Add Step
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{editedTemplate.steps.map((step, index) => (
|
||||
<Card key={step.id} className={editingStepId === step.id ? 'border-primary' : ''}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{index + 1}</Badge>
|
||||
<span className="font-medium text-sm">{step.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveStep(step.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveStep(step.id, 'down')}
|
||||
disabled={index === editedTemplate.steps.length - 1}
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingStepId(editingStepId === step.id ? null : step.id)}
|
||||
>
|
||||
{editingStepId === step.id ? <MinusCircle size={16} /> : <PlusCircle size={16} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStep(step.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{editingStepId === step.id && (
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Step Name</Label>
|
||||
<Input
|
||||
value={step.name}
|
||||
onChange={(e) => updateStep(step.id, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Approver Role</Label>
|
||||
<Select
|
||||
value={step.approverRole}
|
||||
onValueChange={(value) => updateStep(step.id, { approverRole: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{approverRoles.map(role => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description (Optional)</Label>
|
||||
<Input
|
||||
value={step.description || ''}
|
||||
onChange={(e) => updateStep(step.id, { description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={step.requiresComments}
|
||||
onCheckedChange={(checked) => updateStep(step.id, { requiresComments: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">Requires Comments</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={step.canSkip}
|
||||
onCheckedChange={(checked) => updateStep(step.id, { canSkip: checked })}
|
||||
/>
|
||||
<Label className="cursor-pointer">Can Skip</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Escalation Rules</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addEscalationRule(step.id)}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{step.escalationRules?.map((rule) => (
|
||||
<Card key={rule.id} className="bg-muted/30">
|
||||
<CardContent className="p-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Hours Until Escalation</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={rule.hoursUntilEscalation}
|
||||
onChange={(e) => updateEscalationRule(step.id, rule.id, { hoursUntilEscalation: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Escalate To</Label>
|
||||
<Select
|
||||
value={rule.escalateTo}
|
||||
onValueChange={(value) => updateEscalationRule(step.id, rule.id, { escalateTo: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{approverRoles.map(role => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={rule.notifyOriginalApprover}
|
||||
onCheckedChange={(checked) => updateEscalationRule(step.id, rule.id, { notifyOriginalApprover: checked })}
|
||||
/>
|
||||
<Label className="text-xs cursor-pointer">Notify Original Approver</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEscalationRule(step.id, rule.id)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<X size={16} className="mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
<Check size={16} className="mr-1" />
|
||||
Save Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export { useAuditLog } from './use-audit-log'
|
||||
export { useRecurringSchedule } from './use-recurring-schedule'
|
||||
export { useComplianceCheck } from './use-compliance-check'
|
||||
export { useApprovalWorkflow } from './use-approval-workflow'
|
||||
export { useApprovalWorkflowTemplates } from './use-approval-workflow-templates'
|
||||
export { useDataExport } from './use-data-export'
|
||||
export { useHistory } from './use-history'
|
||||
export { useSortableData } from './use-sortable-data'
|
||||
|
||||
280
src/hooks/use-approval-workflow-templates.ts
Normal file
280
src/hooks/use-approval-workflow-templates.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useIndexedDBState } from './use-indexed-db-state'
|
||||
|
||||
export interface ApprovalStepTemplate {
|
||||
id: string
|
||||
order: number
|
||||
name: string
|
||||
description?: string
|
||||
approverRole: string
|
||||
requiresComments: boolean
|
||||
canSkip: boolean
|
||||
skipConditions?: StepCondition[]
|
||||
autoApprovalConditions?: StepCondition[]
|
||||
escalationRules?: EscalationRule[]
|
||||
}
|
||||
|
||||
export interface StepCondition {
|
||||
id: string
|
||||
field: string
|
||||
operator: 'equals' | 'greaterThan' | 'lessThan' | 'contains' | 'notEquals'
|
||||
value: string | number
|
||||
logic?: 'AND' | 'OR'
|
||||
}
|
||||
|
||||
export interface EscalationRule {
|
||||
id: string
|
||||
hoursUntilEscalation: number
|
||||
escalateTo: string
|
||||
notifyOriginalApprover: boolean
|
||||
}
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
batchType: 'payroll' | 'invoice' | 'timesheet' | 'expense' | 'compliance' | 'purchase-order'
|
||||
isActive: boolean
|
||||
isDefault: boolean
|
||||
steps: ApprovalStepTemplate[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createdBy?: string
|
||||
metadata?: {
|
||||
color?: string
|
||||
icon?: string
|
||||
tags?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function useApprovalWorkflowTemplates() {
|
||||
const [templates = [], setTemplates] = useIndexedDBState<WorkflowTemplate[]>(
|
||||
'workflow-templates',
|
||||
[]
|
||||
)
|
||||
|
||||
const createTemplate = useCallback(
|
||||
(name: string, batchType: WorkflowTemplate['batchType'], description: string = ''): WorkflowTemplate => {
|
||||
const newTemplate: WorkflowTemplate = {
|
||||
id: `TPL-${Date.now()}`,
|
||||
name,
|
||||
description,
|
||||
batchType,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-1`,
|
||||
order: 0,
|
||||
name: 'Initial Review',
|
||||
description: 'First level approval',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false
|
||||
}
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
setTemplates((current) => [...(current || []), newTemplate])
|
||||
return newTemplate
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const updateTemplate = useCallback(
|
||||
(templateId: string, updates: Partial<WorkflowTemplate>) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
return {
|
||||
...template,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return template
|
||||
})
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const deleteTemplate = useCallback(
|
||||
(templateId: string) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.filter((template) => template.id !== templateId)
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const duplicateTemplate = useCallback(
|
||||
(templateId: string) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
const original = current.find((t) => t.id === templateId)
|
||||
if (!original) return current
|
||||
|
||||
const duplicate: WorkflowTemplate = {
|
||||
...original,
|
||||
id: `TPL-${Date.now()}`,
|
||||
name: `${original.name} (Copy)`,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
steps: original.steps.map((step, index) => ({
|
||||
...step,
|
||||
id: `STEP-${Date.now()}-${index}`
|
||||
}))
|
||||
}
|
||||
|
||||
return [...current, duplicate]
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const addStep = useCallback(
|
||||
(templateId: string, step: Omit<ApprovalStepTemplate, 'id' | 'order'>) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
const newStep: ApprovalStepTemplate = {
|
||||
...step,
|
||||
id: `STEP-${Date.now()}`,
|
||||
order: template.steps.length
|
||||
}
|
||||
return {
|
||||
...template,
|
||||
steps: [...template.steps, newStep],
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return template
|
||||
})
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const updateStep = useCallback(
|
||||
(templateId: string, stepId: string, updates: Partial<ApprovalStepTemplate>) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
return {
|
||||
...template,
|
||||
steps: template.steps.map((step) =>
|
||||
step.id === stepId ? { ...step, ...updates } : step
|
||||
),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return template
|
||||
})
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const removeStep = useCallback(
|
||||
(templateId: string, stepId: string) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
const updatedSteps = template.steps
|
||||
.filter((step) => step.id !== stepId)
|
||||
.map((step, index) => ({ ...step, order: index }))
|
||||
|
||||
return {
|
||||
...template,
|
||||
steps: updatedSteps,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return template
|
||||
})
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const reorderSteps = useCallback(
|
||||
(templateId: string, stepIds: string[]) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => {
|
||||
if (template.id === templateId) {
|
||||
const reorderedSteps = stepIds
|
||||
.map((id) => template.steps.find((s) => s.id === id))
|
||||
.filter((s): s is ApprovalStepTemplate => s !== undefined)
|
||||
.map((step, index) => ({ ...step, order: index }))
|
||||
|
||||
return {
|
||||
...template,
|
||||
steps: reorderedSteps,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return template
|
||||
})
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const setDefaultTemplate = useCallback(
|
||||
(templateId: string, batchType: WorkflowTemplate['batchType']) => {
|
||||
setTemplates((current) => {
|
||||
if (!current) return []
|
||||
return current.map((template) => ({
|
||||
...template,
|
||||
isDefault: template.id === templateId && template.batchType === batchType,
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
})
|
||||
},
|
||||
[setTemplates]
|
||||
)
|
||||
|
||||
const getTemplatesByBatchType = useCallback(
|
||||
(batchType: WorkflowTemplate['batchType']) => {
|
||||
return templates.filter((t) => t.batchType === batchType)
|
||||
},
|
||||
[templates]
|
||||
)
|
||||
|
||||
const getDefaultTemplate = useCallback(
|
||||
(batchType: WorkflowTemplate['batchType']) => {
|
||||
return templates.find((t) => t.batchType === batchType && t.isDefault)
|
||||
},
|
||||
[templates]
|
||||
)
|
||||
|
||||
const getActiveTemplates = useCallback(() => {
|
||||
return templates.filter((t) => t.isActive)
|
||||
}, [templates])
|
||||
|
||||
return {
|
||||
templates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
addStep,
|
||||
updateStep,
|
||||
removeStep,
|
||||
reorderSteps,
|
||||
setDefaultTemplate,
|
||||
getTemplatesByBatchType,
|
||||
getDefaultTemplate,
|
||||
getActiveTemplates
|
||||
}
|
||||
}
|
||||
250
src/hooks/use-sample-workflow-templates.ts
Normal file
250
src/hooks/use-sample-workflow-templates.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useApprovalWorkflowTemplates, type WorkflowTemplate } from './use-approval-workflow-templates'
|
||||
|
||||
export function useSampleWorkflowTemplates() {
|
||||
const { templates, createTemplate, updateTemplate, addStep, setDefaultTemplate } = useApprovalWorkflowTemplates()
|
||||
|
||||
useEffect(() => {
|
||||
if (templates.length === 0) {
|
||||
const payrollTemplate = createTemplate(
|
||||
'Standard Payroll Approval',
|
||||
'payroll',
|
||||
'Standard two-step approval process for payroll batches'
|
||||
)
|
||||
|
||||
updateTemplate(payrollTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-1`,
|
||||
order: 0,
|
||||
name: 'Payroll Manager Review',
|
||||
description: 'Initial review of payroll calculations',
|
||||
approverRole: 'Payroll Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false,
|
||||
escalationRules: [
|
||||
{
|
||||
id: `ESC-${Date.now()}-1`,
|
||||
hoursUntilEscalation: 24,
|
||||
escalateTo: 'Finance Manager',
|
||||
notifyOriginalApprover: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-2`,
|
||||
order: 1,
|
||||
name: 'Finance Manager Approval',
|
||||
description: 'Final approval and authorization for payment',
|
||||
approverRole: 'Finance Manager',
|
||||
requiresComments: true,
|
||||
canSkip: false,
|
||||
escalationRules: [
|
||||
{
|
||||
id: `ESC-${Date.now()}-2`,
|
||||
hoursUntilEscalation: 48,
|
||||
escalateTo: 'CFO',
|
||||
notifyOriginalApprover: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const invoiceTemplate = createTemplate(
|
||||
'Client Invoice Approval',
|
||||
'invoice',
|
||||
'Single-step approval for standard client invoices'
|
||||
)
|
||||
|
||||
updateTemplate(invoiceTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-3`,
|
||||
order: 0,
|
||||
name: 'Billing Manager Approval',
|
||||
description: 'Review invoice accuracy and client terms',
|
||||
approverRole: 'Finance Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const largeInvoiceTemplate = createTemplate(
|
||||
'Large Invoice Approval',
|
||||
'invoice',
|
||||
'Multi-step approval for invoices over threshold'
|
||||
)
|
||||
|
||||
updateTemplate(largeInvoiceTemplate.id, {
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-4`,
|
||||
order: 0,
|
||||
name: 'Billing Manager Review',
|
||||
description: 'Initial review of large invoice',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-5`,
|
||||
order: 1,
|
||||
name: 'Senior Manager Approval',
|
||||
description: 'Secondary review for high-value invoices',
|
||||
approverRole: 'Senior Manager',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-6`,
|
||||
order: 2,
|
||||
name: 'Director Authorization',
|
||||
description: 'Final authorization for large amounts',
|
||||
approverRole: 'Director',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const timesheetTemplate = createTemplate(
|
||||
'Timesheet Batch Approval',
|
||||
'timesheet',
|
||||
'Quick approval for timesheet batches'
|
||||
)
|
||||
|
||||
updateTemplate(timesheetTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-7`,
|
||||
order: 0,
|
||||
name: 'Operations Manager Approval',
|
||||
description: 'Review timesheet accuracy',
|
||||
approverRole: 'Operations Manager',
|
||||
requiresComments: false,
|
||||
canSkip: true,
|
||||
skipConditions: [
|
||||
{
|
||||
id: `COND-${Date.now()}`,
|
||||
field: 'totalHours',
|
||||
operator: 'lessThan',
|
||||
value: 100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const expenseTemplate = createTemplate(
|
||||
'Expense Claim Approval',
|
||||
'expense',
|
||||
'Two-step approval for expense claims'
|
||||
)
|
||||
|
||||
updateTemplate(expenseTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-8`,
|
||||
order: 0,
|
||||
name: 'Line Manager Approval',
|
||||
description: 'Verify expense legitimacy',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-9`,
|
||||
order: 1,
|
||||
name: 'Finance Review',
|
||||
description: 'Final approval and payment authorization',
|
||||
approverRole: 'Finance Manager',
|
||||
requiresComments: false,
|
||||
canSkip: true,
|
||||
skipConditions: [
|
||||
{
|
||||
id: `COND-${Date.now()}-2`,
|
||||
field: 'amount',
|
||||
operator: 'lessThan',
|
||||
value: 500
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const complianceTemplate = createTemplate(
|
||||
'Compliance Document Approval',
|
||||
'compliance',
|
||||
'Rigorous approval for compliance submissions'
|
||||
)
|
||||
|
||||
updateTemplate(complianceTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-10`,
|
||||
order: 0,
|
||||
name: 'Compliance Officer Review',
|
||||
description: 'Check document completeness',
|
||||
approverRole: 'Compliance Officer',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-11`,
|
||||
order: 1,
|
||||
name: 'Legal Review',
|
||||
description: 'Legal verification of compliance',
|
||||
approverRole: 'Director',
|
||||
requiresComments: true,
|
||||
canSkip: false
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const purchaseOrderTemplate = createTemplate(
|
||||
'Purchase Order Approval',
|
||||
'purchase-order',
|
||||
'Standard PO approval workflow'
|
||||
)
|
||||
|
||||
updateTemplate(purchaseOrderTemplate.id, {
|
||||
isDefault: true,
|
||||
steps: [
|
||||
{
|
||||
id: `STEP-${Date.now()}-12`,
|
||||
order: 0,
|
||||
name: 'Manager Approval',
|
||||
description: 'Verify purchase necessity',
|
||||
approverRole: 'Manager',
|
||||
requiresComments: false,
|
||||
canSkip: false
|
||||
},
|
||||
{
|
||||
id: `STEP-${Date.now()}-13`,
|
||||
order: 1,
|
||||
name: 'Finance Approval',
|
||||
description: 'Budget and payment approval',
|
||||
approverRole: 'Finance Manager',
|
||||
requiresComments: true,
|
||||
canSkip: false,
|
||||
escalationRules: [
|
||||
{
|
||||
id: `ESC-${Date.now()}-3`,
|
||||
hoursUntilEscalation: 72,
|
||||
escalateTo: 'CFO',
|
||||
notifyOriginalApprover: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const viewPreloadMap: Record<View, () => Promise<any>> = {
|
||||
'translation-demo': () => import('@/components/TranslationDemo'),
|
||||
'profile': () => import('@/components/views/profile-view'),
|
||||
'roles-permissions': () => import('@/components/views/roles-permissions-view'),
|
||||
'workflow-templates': () => import('@/components/ApprovalWorkflowTemplateManager'),
|
||||
}
|
||||
|
||||
const preloadedViews = new Set<View>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
|
||||
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions'
|
||||
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide' | 'component-showcase' | 'business-logic-demo' | 'data-admin' | 'translation-demo' | 'profile' | 'roles-permissions' | 'workflow-templates'
|
||||
|
||||
type Locale = 'en' | 'es' | 'fr'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user