This commit is contained in:
phaichayon
2026-06-27 10:04:43 +07:00
parent 2ace5dbb83
commit 3fdd681dd0
29 changed files with 8593 additions and 15 deletions

View File

@@ -0,0 +1,87 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createApprovalEscalationPolicy,
createApprovalReminderPolicy,
deleteApprovalEscalationPolicy,
deleteApprovalReminderPolicy,
runApprovalAutomation,
updateApprovalEscalationPolicy,
updateApprovalReminderPolicy
} from './service';
import { approvalAutomationKeys } from './queries';
import type { EscalationPolicyPayload, ReminderPolicyPayload } from './types';
async function invalidateAutomationQueries() {
const queryClient = getQueryClient();
await Promise.all([
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.pendingQueue() }),
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.reminderPolicies() }),
queryClient.invalidateQueries({ queryKey: approvalAutomationKeys.escalationPolicies() })
]);
}
export const runApprovalAutomationMutation = mutationOptions({
mutationFn: () => runApprovalAutomation(),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const createApprovalReminderPolicyMutation = mutationOptions({
mutationFn: (data: ReminderPolicyPayload) => createApprovalReminderPolicy(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const updateApprovalReminderPolicyMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ReminderPolicyPayload }) =>
updateApprovalReminderPolicy(id, values),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const deleteApprovalReminderPolicyMutation = mutationOptions({
mutationFn: (id: string) => deleteApprovalReminderPolicy(id),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const createApprovalEscalationPolicyMutation = mutationOptions({
mutationFn: (data: EscalationPolicyPayload) => createApprovalEscalationPolicy(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const updateApprovalEscalationPolicyMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EscalationPolicyPayload }) =>
updateApprovalEscalationPolicy(id, values),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});
export const deleteApprovalEscalationPolicyMutation = mutationOptions({
mutationFn: (id: string) => deleteApprovalEscalationPolicy(id),
onSettled: async (_data, error) => {
if (!error) {
await invalidateAutomationQueries();
}
}
});

View File

@@ -0,0 +1,31 @@
import { queryOptions } from '@tanstack/react-query';
import {
getApprovalEscalationPolicies,
getApprovalReminderPolicies,
getPendingApprovalAutomationQueue
} from './service';
export const approvalAutomationKeys = {
all: ['crm-approval-automation'] as const,
pendingQueue: () => [...approvalAutomationKeys.all, 'pending-queue'] as const,
reminderPolicies: () => [...approvalAutomationKeys.all, 'reminder-policies'] as const,
escalationPolicies: () => [...approvalAutomationKeys.all, 'escalation-policies'] as const
};
export const pendingApprovalAutomationQueueOptions = () =>
queryOptions({
queryKey: approvalAutomationKeys.pendingQueue(),
queryFn: () => getPendingApprovalAutomationQueue()
});
export const approvalReminderPoliciesOptions = () =>
queryOptions({
queryKey: approvalAutomationKeys.reminderPolicies(),
queryFn: () => getApprovalReminderPolicies()
});
export const approvalEscalationPoliciesOptions = () =>
queryOptions({
queryKey: approvalAutomationKeys.escalationPolicies(),
queryFn: () => getApprovalEscalationPolicies()
});

View File

@@ -0,0 +1,70 @@
import { apiClient } from '@/lib/api-client';
import type {
ApprovalAutomationRunResponse,
EscalationPoliciesResponse,
EscalationPolicyPayload,
MutationSuccessResponse,
PendingApprovalQueueResponse,
ReminderPoliciesResponse,
ReminderPolicyPayload
} from './types';
const BASE = '/crm/approval';
export async function getPendingApprovalAutomationQueue() {
return apiClient<PendingApprovalQueueResponse>(`${BASE}/automation/pending`);
}
export async function runApprovalAutomation() {
return apiClient<ApprovalAutomationRunResponse>(`${BASE}/automation/run`, {
method: 'POST'
});
}
export async function getApprovalReminderPolicies() {
return apiClient<ReminderPoliciesResponse>(`${BASE}/reminder-policies`);
}
export async function createApprovalReminderPolicy(data: ReminderPolicyPayload) {
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateApprovalReminderPolicy(id: string, data: ReminderPolicyPayload) {
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteApprovalReminderPolicy(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE}/reminder-policies/${id}`, {
method: 'DELETE'
});
}
export async function getApprovalEscalationPolicies() {
return apiClient<EscalationPoliciesResponse>(`${BASE}/escalation-policies`);
}
export async function createApprovalEscalationPolicy(data: EscalationPolicyPayload) {
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateApprovalEscalationPolicy(id: string, data: EscalationPolicyPayload) {
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteApprovalEscalationPolicy(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE}/escalation-policies/${id}`, {
method: 'DELETE'
});
}

View File

@@ -0,0 +1,112 @@
import type {
ApprovalEscalationTargetType,
ApprovalTimeoutAction
} from '../types';
export interface ReminderPolicyItem {
id: string;
organizationId: string;
workflowId: string;
workflowName: string;
stepNumber: number;
slaHours: number;
reminderOffsetsHours: number[];
calendarMode: 'calendar_days';
timeoutAction: ApprovalTimeoutAction;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface EscalationPolicyItem {
id: string;
organizationId: string;
workflowId: string;
workflowName: string;
stepNumber: number;
triggerAfterHours: number;
targetType: ApprovalEscalationTargetType;
targetValue: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PendingApprovalQueueItem {
approvalRequestId: string;
workflowId: string;
workflowName: string;
entityType: string;
entityId: string;
requestedBy: string;
requestedAt: string;
currentStep: number;
currentStepRoleCode: string | null;
currentStepRoleName: string | null;
currentStepStartedAt: string;
waitingHours: number;
slaHours: number | null;
remainingHours: number | null;
dueAt: string | null;
isDueToday: boolean;
isOverdue: boolean;
isEscalated: boolean;
}
export interface PendingApprovalQueueResponse {
success: boolean;
time: string;
message: string;
items: PendingApprovalQueueItem[];
summary: {
pending: number;
dueToday: number;
overdue: number;
escalated: number;
};
}
export interface ReminderPoliciesResponse {
success: boolean;
time: string;
message: string;
items: ReminderPolicyItem[];
}
export interface EscalationPoliciesResponse {
success: boolean;
time: string;
message: string;
items: EscalationPolicyItem[];
}
export interface ReminderPolicyPayload {
workflowId: string;
stepNumber: number;
slaHours: number;
reminderOffsetsHours: number[];
calendarMode?: 'calendar_days';
timeoutAction?: ApprovalTimeoutAction;
isActive?: boolean;
}
export interface EscalationPolicyPayload {
workflowId: string;
stepNumber: number;
triggerAfterHours: number;
targetType: ApprovalEscalationTargetType;
targetValue?: string | null;
isActive?: boolean;
}
export interface ApprovalAutomationRunResponse {
success: boolean;
message: string;
processedCount: number;
processedAt: string;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,331 @@
'use client';
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries';
import {
createApprovalEscalationPolicyMutation,
deleteApprovalEscalationPolicyMutation,
updateApprovalEscalationPolicyMutation
} from '../api/mutations';
import { approvalEscalationPoliciesOptions } from '../api/queries';
import type { EscalationPolicyItem, EscalationPolicyPayload } from '../api/types';
type EscalationFormState = {
workflowId: string;
stepNumber: string;
triggerAfterHours: string;
targetType: EscalationPolicyPayload['targetType'];
targetValue: string;
isActive: boolean;
};
function toEscalationState(policy?: EscalationPolicyItem): EscalationFormState {
return {
workflowId: policy?.workflowId ?? '',
stepNumber: String(policy?.stepNumber ?? 1),
triggerAfterHours: String(policy?.triggerAfterHours ?? 24),
targetType: policy?.targetType ?? 'manager',
targetValue: policy?.targetValue ?? '',
isActive: policy?.isActive ?? true
};
}
function EscalationPolicyDialog({
open,
onOpenChange,
policy
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
policy?: EscalationPolicyItem;
}) {
const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions());
const [state, setState] = React.useState<EscalationFormState>(toEscalationState(policy));
const createMutation = useMutation({
...createApprovalEscalationPolicyMutation,
onSuccess: () => {
toast.success('Escalation policy created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateApprovalEscalationPolicyMutation,
onSuccess: () => {
toast.success('Escalation policy updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? '';
setState({ ...toEscalationState(policy), workflowId });
}
}, [open, policy, workflowsQuery.data.items]);
const isPending = createMutation.isPending || updateMutation.isPending;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: EscalationPolicyPayload = {
workflowId: state.workflowId,
stepNumber: Number(state.stepNumber),
triggerAfterHours: Number(state.triggerAfterHours),
targetType: state.targetType,
targetValue: state.targetValue.trim() || null,
isActive: state.isActive
};
if (policy) {
await updateMutation.mutateAsync({ id: policy.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{policy ? 'Edit Escalation Policy' : 'Create Escalation Policy'}</DialogTitle>
</DialogHeader>
<form className='space-y-4' onSubmit={onSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Workflow</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.workflowId}
onChange={(event) =>
setState((current) => ({ ...current, workflowId: event.target.value }))
}
>
{workflowsQuery.data.items.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Step Number</div>
<Input
type='number'
min={1}
value={state.stepNumber}
onChange={(event) =>
setState((current) => ({ ...current, stepNumber: event.target.value }))
}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Trigger After Hours</div>
<Input
type='number'
min={1}
value={state.triggerAfterHours}
onChange={(event) =>
setState((current) => ({
...current,
triggerAfterHours: event.target.value
}))
}
/>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Target Type</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.targetType}
onChange={(event) =>
setState((current) => ({
...current,
targetType: event.target.value as EscalationFormState['targetType']
}))
}
>
<option value='manager'>Manager</option>
<option value='requester'>Requester</option>
<option value='explicit_user'>Explicit User</option>
<option value='role'>Role</option>
<option value='permission_group'>Permission Group</option>
</select>
</div>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Target Value</div>
{state.targetType === 'role' ? (
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.targetValue}
onChange={(event) =>
setState((current) => ({ ...current, targetValue: event.target.value }))
}
>
<option value=''>Select role</option>
{BUSINESS_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
) : state.targetType === 'permission_group' ? (
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.targetValue}
onChange={(event) =>
setState((current) => ({ ...current, targetValue: event.target.value }))
}
>
<option value=''>Select permission</option>
{Object.values(PERMISSIONS).map((permission) => (
<option key={permission} value={permission}>
{permission}
</option>
))}
</select>
) : (
<Input
value={state.targetValue}
onChange={(event) =>
setState((current) => ({ ...current, targetValue: event.target.value }))
}
placeholder={
state.targetType === 'manager'
? 'Optional role code, otherwise organization admins'
: state.targetType === 'explicit_user'
? 'User id'
: 'Optional'
}
/>
)}
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Policy</div>
<div className='text-muted-foreground text-sm'>
Inactive escalation policies are ignored by the scheduler.
</div>
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) =>
setState((current) => ({ ...current, isActive: checked }))
}
/>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{policy ? 'Save Policy' : 'Create Policy'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function EscalationPolicySettings() {
const escalationPoliciesQuery = useSuspenseQuery(approvalEscalationPoliciesOptions());
const [createOpen, setCreateOpen] = React.useState(false);
const [editingPolicy, setEditingPolicy] = React.useState<EscalationPolicyItem | null>(null);
const deleteMutation = useMutation({
...deleteApprovalEscalationPolicyMutation,
onSuccess: () => toast.success('Escalation policy deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<div className='space-y-6'>
<div className='flex items-center justify-between rounded-lg border p-4'>
<div>
<div className='font-medium'>Approval Escalation Policies</div>
<div className='text-muted-foreground text-sm'>
Define who gets notified when a pending step waits too long.
</div>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Create Policy
</Button>
</div>
<div className='space-y-4'>
{escalationPoliciesQuery.data.items.map((policy) => (
<div key={policy.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<div className='font-medium'>
{policy.workflowName} | Step {policy.stepNumber}
</div>
<div className='text-muted-foreground mt-1 text-sm'>
Escalate after {policy.triggerAfterHours} hours to {policy.targetType}
{policy.targetValue ? `: ${policy.targetValue}` : ''}
</div>
<div className='mt-3 flex flex-wrap gap-2'>
<Badge variant='outline'>{policy.targetType}</Badge>
<Badge variant='outline'>{policy.triggerAfterHours}h</Badge>
<Badge variant={policy.isActive ? 'secondary' : 'outline'}>
{policy.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className='flex gap-2'>
<Button size='sm' variant='outline' onClick={() => setEditingPolicy(policy)}>
Edit
</Button>
<Button
size='sm'
variant='outline'
onClick={() => deleteMutation.mutate(policy.id)}
isLoading={deleteMutation.isPending}
>
Delete
</Button>
</div>
</div>
</div>
))}
{escalationPoliciesQuery.data.items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No escalation policies configured yet.
</div>
) : null}
</div>
<EscalationPolicyDialog open={createOpen} onOpenChange={setCreateOpen} />
<EscalationPolicyDialog
open={editingPolicy !== null}
onOpenChange={(open) => {
if (!open) {
setEditingPolicy(null);
}
}}
policy={editingPolicy ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,369 @@
'use client';
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries';
import {
createApprovalReminderPolicyMutation,
deleteApprovalReminderPolicyMutation,
runApprovalAutomationMutation,
updateApprovalReminderPolicyMutation
} from '../api/mutations';
import {
approvalReminderPoliciesOptions,
pendingApprovalAutomationQueueOptions
} from '../api/queries';
import type { ReminderPolicyItem, ReminderPolicyPayload } from '../api/types';
type ReminderFormState = {
workflowId: string;
stepNumber: string;
slaHours: string;
reminderOffsets: string;
timeoutAction: ReminderPolicyPayload['timeoutAction'];
isActive: boolean;
};
function toReminderState(policy?: ReminderPolicyItem): ReminderFormState {
return {
workflowId: policy?.workflowId ?? '',
stepNumber: String(policy?.stepNumber ?? 1),
slaHours: String(policy?.slaHours ?? 24),
reminderOffsets: policy?.reminderOffsetsHours.join(', ') ?? '24, 48',
timeoutAction: policy?.timeoutAction ?? 'none',
isActive: policy?.isActive ?? true
};
}
function parseOffsets(value: string) {
return value
.split(',')
.map((item) => Number(item.trim()))
.filter((item) => Number.isFinite(item) && item > 0);
}
function ReminderPolicyDialog({
open,
onOpenChange,
policy
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
policy?: ReminderPolicyItem;
}) {
const workflowsQuery = useSuspenseQuery(approvalWorkflowsQueryOptions());
const [state, setState] = React.useState<ReminderFormState>(toReminderState(policy));
const createMutation = useMutation({
...createApprovalReminderPolicyMutation,
onSuccess: () => {
toast.success('Reminder policy created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateApprovalReminderPolicyMutation,
onSuccess: () => {
toast.success('Reminder policy updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
const workflowId = policy?.workflowId ?? workflowsQuery.data.items[0]?.id ?? '';
setState({ ...toReminderState(policy), workflowId });
}
}, [open, policy, workflowsQuery.data.items]);
const isPending = createMutation.isPending || updateMutation.isPending;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ReminderPolicyPayload = {
workflowId: state.workflowId,
stepNumber: Number(state.stepNumber),
slaHours: Number(state.slaHours),
reminderOffsetsHours: parseOffsets(state.reminderOffsets),
timeoutAction: state.timeoutAction,
isActive: state.isActive,
calendarMode: 'calendar_days'
};
if (policy) {
await updateMutation.mutateAsync({ id: policy.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{policy ? 'Edit Reminder Policy' : 'Create Reminder Policy'}</DialogTitle>
</DialogHeader>
<form className='space-y-4' onSubmit={onSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Workflow</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.workflowId}
onChange={(event) =>
setState((current) => ({ ...current, workflowId: event.target.value }))
}
>
{workflowsQuery.data.items.map((workflow) => (
<option key={workflow.id} value={workflow.id}>
{workflow.name}
</option>
))}
</select>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Step Number</div>
<Input
type='number'
min={1}
value={state.stepNumber}
onChange={(event) =>
setState((current) => ({ ...current, stepNumber: event.target.value }))
}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>SLA Hours</div>
<Input
type='number'
min={1}
value={state.slaHours}
onChange={(event) =>
setState((current) => ({ ...current, slaHours: event.target.value }))
}
/>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Timeout Action</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.timeoutAction}
onChange={(event) =>
setState((current) => ({
...current,
timeoutAction: event.target.value as ReminderFormState['timeoutAction']
}))
}
>
<option value='none'>None</option>
<option value='auto_reject'>Auto Reject</option>
<option value='auto_cancel'>Auto Cancel</option>
<option value='auto_escalate'>Auto Escalate</option>
</select>
</div>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Reminder Offsets (hours)</div>
<Textarea
value={state.reminderOffsets}
onChange={(event) =>
setState((current) => ({ ...current, reminderOffsets: event.target.value }))
}
placeholder='24, 48, 72'
/>
<div className='text-muted-foreground text-xs'>
Comma-separated hours after step start.
</div>
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Policy</div>
<div className='text-muted-foreground text-sm'>
Inactive policies are ignored by the scheduler.
</div>
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) =>
setState((current) => ({ ...current, isActive: checked }))
}
/>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{policy ? 'Save Policy' : 'Create Policy'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function ReminderPolicySettings() {
const reminderPoliciesQuery = useSuspenseQuery(approvalReminderPoliciesOptions());
const pendingQueueQuery = useSuspenseQuery(pendingApprovalAutomationQueueOptions());
const [createOpen, setCreateOpen] = React.useState(false);
const [editingPolicy, setEditingPolicy] = React.useState<ReminderPolicyItem | null>(null);
const runAutomation = useMutation({
...runApprovalAutomationMutation,
onSuccess: (result) => {
toast.success(`Automation completed. ${result.processedCount} request(s) checked.`);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Run failed')
});
const deleteMutation = useMutation({
...deleteApprovalReminderPolicyMutation,
onSuccess: () => toast.success('Reminder policy deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
const queue = pendingQueueQuery.data;
return (
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Pending</div>
<div className='text-2xl font-semibold'>{queue.summary.pending}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Due Today</div>
<div className='text-2xl font-semibold'>{queue.summary.dueToday}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Overdue</div>
<div className='text-2xl font-semibold'>{queue.summary.overdue}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Escalated</div>
<div className='text-2xl font-semibold'>{queue.summary.escalated}</div>
</div>
</div>
<div className='flex items-center justify-between rounded-lg border p-4'>
<div>
<div className='font-medium'>Approval Automation Scheduler</div>
<div className='text-muted-foreground text-sm'>
Manually execute reminder, escalation, and timeout checks for all pending approvals.
</div>
</div>
<div className='flex gap-2'>
<Button variant='outline' onClick={() => setCreateOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Create Policy
</Button>
<Button onClick={() => runAutomation.mutate()} isLoading={runAutomation.isPending}>
Run Automation
</Button>
</div>
</div>
<div className='rounded-lg border p-4'>
<div className='mb-4 font-medium'>Pending Queue Monitor</div>
{queue.items.length === 0 ? (
<div className='text-muted-foreground text-sm'>No pending approvals right now.</div>
) : (
<div className='space-y-3'>
{queue.items.slice(0, 10).map((item) => (
<div key={item.approvalRequestId} className='rounded-lg border p-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<div className='font-medium'>
{item.workflowName} | Step {item.currentStep}
</div>
<div className='text-muted-foreground text-sm'>
{item.currentStepRoleName ?? '-'} | Waiting {item.waitingHours}h
</div>
</div>
<div className='flex flex-wrap gap-2'>
{item.isOverdue ? <Badge variant='destructive'>Overdue</Badge> : null}
{item.isDueToday ? <Badge variant='secondary'>Due Today</Badge> : null}
{item.isEscalated ? <Badge variant='outline'>Escalated</Badge> : null}
{item.slaHours !== null ? (
<Badge variant='outline'>SLA {item.slaHours}h</Badge>
) : null}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className='space-y-4'>
{reminderPoliciesQuery.data.items.map((policy) => (
<div key={policy.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-4'>
<div>
<div className='font-medium'>
{policy.workflowName} | Step {policy.stepNumber}
</div>
<div className='text-muted-foreground mt-1 text-sm'>
Reminders at {policy.reminderOffsetsHours.join(', ') || '-'} hours
</div>
<div className='mt-3 flex flex-wrap gap-2'>
<Badge variant='outline'>SLA {policy.slaHours}h</Badge>
<Badge variant='outline'>{policy.timeoutAction}</Badge>
<Badge variant={policy.isActive ? 'secondary' : 'outline'}>
{policy.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className='flex gap-2'>
<Button size='sm' variant='outline' onClick={() => setEditingPolicy(policy)}>
Edit
</Button>
<Button
size='sm'
variant='outline'
onClick={() => deleteMutation.mutate(policy.id)}
isLoading={deleteMutation.isPending}
>
Delete
</Button>
</div>
</div>
</div>
))}
{reminderPoliciesQuery.data.items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No reminder policies configured yet.
</div>
) : null}
</div>
<ReminderPolicyDialog open={createOpen} onOpenChange={setCreateOpen} />
<ReminderPolicyDialog
open={editingPolicy !== null}
onOpenChange={(open) => {
if (!open) {
setEditingPolicy(null);
}
}}
policy={editingPolicy ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const approvalReminderPolicySchema = z.object({
workflowId: z.string().min(1),
stepNumber: z.number().int().min(1),
slaHours: z.number().int().min(1),
reminderOffsetsHours: z.array(z.number().int().min(1)).default([]),
calendarMode: z.literal('calendar_days').default('calendar_days'),
timeoutAction: z
.enum(['none', 'auto_reject', 'auto_cancel', 'auto_escalate'])
.default('none'),
isActive: z.boolean().default(true)
});
export const approvalEscalationPolicySchema = z.object({
workflowId: z.string().min(1),
stepNumber: z.number().int().min(1),
triggerAfterHours: z.number().int().min(1),
targetType: z.enum([
'manager',
'requester',
'explicit_user',
'role',
'permission_group'
]),
targetValue: z.string().trim().optional().nullable(),
isActive: z.boolean().default(true)
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
export type ApprovalTimeoutAction = 'none' | 'auto_reject' | 'auto_cancel' | 'auto_escalate';
export type ApprovalCalendarMode = 'calendar_days';
export type ApprovalEscalationTargetType =
| 'manager'
| 'requester'
| 'explicit_user'
| 'role'
| 'permission_group';
export interface ApprovalReminderPolicyRecord {
id: string;
organizationId: string;
workflowId: string;
workflowName: string;
stepNumber: number;
slaHours: number;
reminderOffsetsHours: number[];
calendarMode: ApprovalCalendarMode;
timeoutAction: ApprovalTimeoutAction;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface ApprovalEscalationPolicyRecord {
id: string;
organizationId: string;
workflowId: string;
workflowName: string;
stepNumber: number;
triggerAfterHours: number;
targetType: ApprovalEscalationTargetType;
targetValue: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface ApprovalReminderPolicyPayload {
workflowId: string;
stepNumber: number;
slaHours: number;
reminderOffsetsHours: number[];
calendarMode?: ApprovalCalendarMode;
timeoutAction?: ApprovalTimeoutAction;
isActive?: boolean;
}
export interface ApprovalEscalationPolicyPayload {
workflowId: string;
stepNumber: number;
triggerAfterHours: number;
targetType: ApprovalEscalationTargetType;
targetValue?: string | null;
isActive?: boolean;
}
export interface PendingApprovalQueueItem {
approvalRequestId: string;
workflowId: string;
workflowName: string;
entityType: string;
entityId: string;
requestedBy: string;
requestedAt: string;
currentStep: number;
currentStepRoleCode: string | null;
currentStepRoleName: string | null;
currentStepStartedAt: string;
waitingHours: number;
slaHours: number | null;
remainingHours: number | null;
dueAt: string | null;
isDueToday: boolean;
isOverdue: boolean;
isEscalated: boolean;
}
export interface PendingApprovalQueueSummary {
pending: number;
dueToday: number;
overdue: number;
escalated: number;
}
export interface PendingApprovalQueueResponse {
items: PendingApprovalQueueItem[];
summary: PendingApprovalQueueSummary;
}

View File

@@ -85,6 +85,9 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
roleName: row.roleName,
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
isRequired: row.isRequired,
slaHours: row.slaHours,
calendarMode: row.calendarMode as ApprovalStepRecord['calendarMode'],
timeoutAction: row.timeoutAction as ApprovalStepRecord['timeoutAction'],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
@@ -99,6 +102,7 @@ function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): Approva
entityType: row.entityType,
entityId: row.entityId,
currentStep: row.currentStep,
currentStepStartedAt: row.currentStepStartedAt.toISOString(),
status: row.status,
requestedBy: row.requestedBy,
requestedAt: row.requestedAt.toISOString(),
@@ -682,7 +686,7 @@ async function syncQuotationStatusFromApproval(
await db.update(crmQuotations).set(nextValues).where(eq(crmQuotations.id, quotationId));
}
async function syncEntityStatusFromApproval(
export async function syncEntityStatusFromApproval(
organizationId: string,
entityType: string,
entityId: string,
@@ -1139,6 +1143,7 @@ export async function submitForApproval(
entityType: payload.entityType,
entityId: payload.entityId,
currentStep: steps[0].stepNumber,
currentStepStartedAt: new Date(),
status: APPROVAL_REQUEST_STATUSES.pending,
requestedBy: userId
})
@@ -1238,6 +1243,7 @@ const steps = await listWorkflowSteps(request.workflowId, organizationId);
.update(crmApprovalRequests)
.set({
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
currentStepStartedAt: nextStep ? new Date() : request.currentStepStartedAt,
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
completedAt: nextStep ? null : new Date(),
updatedAt: new Date()

View File

@@ -34,6 +34,9 @@ export interface ApprovalStepRecord {
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired: boolean;
slaHours: number;
calendarMode: 'calendar_days';
timeoutAction: 'none' | 'auto_reject' | 'auto_cancel' | 'auto_escalate';
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -56,6 +59,7 @@ export interface ApprovalRequestRecord {
entityType: string;
entityId: string;
currentStep: number;
currentStepStartedAt: string;
status: string;
requestedBy: string;
requestedAt: string;

View File

@@ -55,6 +55,24 @@ const EVENT_RULES: Record<string, EventRule> = {
'approval.returned': {
severity: 'warning',
recipients: [{ type: 'approval_requester' }]
},
'approval.reminder': {
severity: 'warning',
recipients: [{ type: 'approval_current_step_approvers', excludeActor: true }]
},
'approval.escalated': {
severity: 'warning',
recipients: [
{ type: 'explicit_user', excludeActor: true },
{ type: 'approval_requester' }
]
},
'approval.timeout': {
severity: 'error',
recipients: [
{ type: 'explicit_user', excludeActor: true },
{ type: 'approval_requester' }
]
}
};
@@ -77,7 +95,9 @@ function mapEventRecord(row: typeof appNotificationEvents.$inferSelect): Notific
};
}
function mapTemplateRecord(row: typeof appNotificationTemplates.$inferSelect): NotificationTemplateRecord {
function mapTemplateRecord(
row: typeof appNotificationTemplates.$inferSelect
): NotificationTemplateRecord {
return {
id: row.id,
organizationId: row.organizationId,
@@ -195,9 +215,7 @@ export async function processNotificationEvent(eventId: string) {
const recipients = [...new Set(recipientLists.flat())];
const existingRows = recipients.length
? await db
.select({
recipientUserId: appNotifications.recipientUserId
})
.select({ recipientUserId: appNotifications.recipientUserId })
.from(appNotifications)
.where(
and(
@@ -207,9 +225,10 @@ export async function processNotificationEvent(eventId: string) {
)
: [];
const existingRecipientIds = new Set(existingRows.map((row) => row.recipientUserId));
const recipientsToCreate = recipients.filter((recipientUserId) => !existingRecipientIds.has(recipientUserId));
const recipientsToCreate = recipients.filter((userId) => !existingRecipientIds.has(userId));
const createdIds: string[] = [];
for (const recipientUserId of recipientsToCreate) {
const rendered = renderNotificationTemplate(template, payload);
const notificationId = crypto.randomUUID();
@@ -250,7 +269,11 @@ export async function processNotificationEvent(eventId: string) {
entityType: 'app_notification',
entityId: notificationId,
action: 'notification_created',
afterData: { recipientUserId, eventId: event.id, eventType: event.eventType }
afterData: {
recipientUserId,
eventId: event.id,
eventType: event.eventType
}
});
}
@@ -292,7 +315,7 @@ export async function processPendingNotificationEvents() {
try {
await processNotificationEvent(row.id);
} catch {
// Errors are recorded on the event row for later inspection.
// Event failures are persisted for inspection.
}
}
}
@@ -351,7 +374,7 @@ export async function publishNotificationEvent(input: PublishNotificationEventIn
try {
await processNotificationEvent(row.id);
} catch {
// Event failures are persisted on the event row and must not block the caller.
// Event failures are recorded and must not block callers.
}
return mapEventRecord(row);