task-f
This commit is contained in:
103
src/features/foundation/approval/components/approval-columns.tsx
Normal file
103
src/features/foundation/approval/components/approval-columns.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { Icons } from '@/components/icons';
|
||||
import type { ApprovalListItem } from '../types';
|
||||
import { ApprovalStatusBadge } from './approval-status-badge';
|
||||
|
||||
export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
|
||||
return [
|
||||
{
|
||||
id: 'entityCode',
|
||||
accessorFn: (row) => row.entityCode ?? row.entityId,
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Request' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link href={`/dashboard/crm/approvals/${row.original.id}`} className='font-medium hover:underline'>
|
||||
{row.original.entityCode ?? row.original.entityId}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.workflowName}
|
||||
{row.original.entityTitle ? ` - ${row.original.entityTitle}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Request',
|
||||
placeholder: 'Search approvals...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => <ApprovalStatusBadge status={row.original.status} />,
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
options: [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'returned', label: 'Returned' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
]
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'entityType',
|
||||
accessorKey: 'entityType',
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Entity' />
|
||||
),
|
||||
cell: ({ row }) => row.original.entityType.replaceAll('_', ' '),
|
||||
meta: {
|
||||
label: 'Entity',
|
||||
variant: 'multiSelect' as const,
|
||||
options: [{ value: 'quotation', label: 'Quotation' }]
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'currentStep',
|
||||
accessorKey: 'currentStep',
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Current Step' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>Step {row.original.currentStep}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.currentStepRoleName ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'requestedBy',
|
||||
accessorFn: (row) => row.requestedByName ?? row.requestedBy,
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Requested By' />
|
||||
),
|
||||
cell: ({ row }) => row.original.requestedByName ?? row.original.requestedBy
|
||||
},
|
||||
{
|
||||
id: 'requestedAt',
|
||||
accessorKey: 'requestedAt',
|
||||
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Requested At' />
|
||||
),
|
||||
cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString()
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { approvalByIdOptions } from '../queries';
|
||||
import { ApprovalRequestPanel } from './approval-request-panel';
|
||||
|
||||
export function ApprovalDetail({
|
||||
approvalId,
|
||||
canApprove,
|
||||
canReject,
|
||||
canReturn,
|
||||
canCancel,
|
||||
activeBusinessRole,
|
||||
isOrgAdmin,
|
||||
currentUserId
|
||||
}: {
|
||||
approvalId: string;
|
||||
canApprove: boolean;
|
||||
canReject: boolean;
|
||||
canReturn: boolean;
|
||||
canCancel: boolean;
|
||||
activeBusinessRole: string | null;
|
||||
isOrgAdmin: boolean;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(approvalByIdOptions(approvalId));
|
||||
const entityHref =
|
||||
data.approval.request.entityType === 'quotation'
|
||||
? `/dashboard/crm/quotations/${data.approval.request.entityId}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ApprovalRequestPanel
|
||||
approval={data.approval}
|
||||
canApprove={canApprove}
|
||||
canReject={canReject}
|
||||
canReturn={canReturn}
|
||||
canCancel={canCancel}
|
||||
activeBusinessRole={activeBusinessRole}
|
||||
isOrgAdmin={isOrgAdmin}
|
||||
currentUserId={currentUserId}
|
||||
entityHref={entityHref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import { approvalsQueryOptions } from '../queries';
|
||||
import { ApprovalsTable } from './approvals-table';
|
||||
|
||||
export default function ApprovalListing() {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
const search = searchParamsCache.get('name');
|
||||
const status = searchParamsCache.get('status');
|
||||
const entityType = searchParamsCache.get('entityType');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(entityType && { entityType }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
void queryClient.prefetchQuery(approvalsQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ApprovalsTable />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useMutation } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
approveApprovalMutation,
|
||||
cancelApprovalMutation,
|
||||
rejectApprovalMutation,
|
||||
returnApprovalMutation
|
||||
} from '../mutations';
|
||||
import type { ApprovalDetailRecord } from '../types';
|
||||
import { ApprovalStatusBadge } from './approval-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalRequestPanel({
|
||||
approval,
|
||||
canApprove,
|
||||
canReject,
|
||||
canReturn,
|
||||
canCancel,
|
||||
activeBusinessRole,
|
||||
isOrgAdmin,
|
||||
currentUserId,
|
||||
entityHref
|
||||
}: {
|
||||
approval: ApprovalDetailRecord;
|
||||
canApprove: boolean;
|
||||
canReject: boolean;
|
||||
canReturn: boolean;
|
||||
canCancel: boolean;
|
||||
activeBusinessRole: string | null;
|
||||
isOrgAdmin: boolean;
|
||||
currentUserId: string;
|
||||
entityHref?: string;
|
||||
}) {
|
||||
const [remark, setRemark] = useState('');
|
||||
const isPending = approval.request.status === 'pending';
|
||||
const canHandleCurrentStep =
|
||||
!!approval.currentStep &&
|
||||
(isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode);
|
||||
const canCancelCurrentRequest =
|
||||
canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId);
|
||||
|
||||
const approveAction = useMutation({
|
||||
...approveApprovalMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Approval step completed');
|
||||
setRemark('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to approve request')
|
||||
});
|
||||
const rejectAction = useMutation({
|
||||
...rejectApprovalMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Approval request rejected');
|
||||
setRemark('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to reject request')
|
||||
});
|
||||
const returnAction = useMutation({
|
||||
...returnApprovalMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Approval request returned to draft');
|
||||
setRemark('');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to return request')
|
||||
});
|
||||
const cancelAction = useMutation({
|
||||
...cancelApprovalMutation,
|
||||
onSuccess: () => toast.success('Approval request cancelled'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Unable to cancel request')
|
||||
});
|
||||
const isActing =
|
||||
approveAction.isPending ||
|
||||
rejectAction.isPending ||
|
||||
returnAction.isPending ||
|
||||
cancelAction.isPending;
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Information</CardTitle>
|
||||
<CardDescription>Current workflow state for this approval request.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Workflow' value={approval.workflow.name} />
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>Status</div>
|
||||
<ApprovalStatusBadge status={approval.request.status} />
|
||||
</div>
|
||||
<FieldItem label='Entity Type' value={approval.request.entityType.replaceAll('_', ' ')} />
|
||||
<FieldItem label='Entity Code' value={approval.entityCode ?? approval.request.entityId} />
|
||||
<FieldItem label='Entity Title' value={approval.entityTitle} />
|
||||
<FieldItem
|
||||
label='Requested At'
|
||||
value={new Date(approval.request.requestedAt).toLocaleString()}
|
||||
/>
|
||||
<FieldItem label='Requested By' value={approval.request.requestedBy} />
|
||||
<FieldItem
|
||||
label='Current Step'
|
||||
value={
|
||||
approval.currentStep
|
||||
? `Step ${approval.currentStep.stepNumber} - ${approval.currentStep.roleName}`
|
||||
: approval.request.status === 'approved'
|
||||
? 'Completed'
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
{entityHref ? (
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<Button asChild variant='outline'>
|
||||
<Link href={entityHref}>
|
||||
<Icons.arrowRight className='mr-2 h-4 w-4' />
|
||||
Open Related Document
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow</CardTitle>
|
||||
<CardDescription>Sequential approver chain configured for this document.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{approval.steps.map((step) => {
|
||||
const isCurrent = approval.currentStep?.id === step.id && isPending;
|
||||
const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className='flex items-center justify-between rounded-lg border p-4'
|
||||
>
|
||||
<div>
|
||||
<div className='font-medium'>
|
||||
Step {step.stepNumber} - {step.roleName}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>{step.roleCode}</div>
|
||||
</div>
|
||||
<Badge variant={isCurrent ? 'secondary' : isCompleted ? 'default' : 'outline'}>
|
||||
{isCurrent ? 'Current' : isCompleted ? 'Completed' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Actions</CardTitle>
|
||||
<CardDescription>
|
||||
Approvers can approve, reject, or return on their assigned step.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.target.value)}
|
||||
placeholder='Optional remark for approval history'
|
||||
rows={4}
|
||||
/>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canApprove && canHandleCurrentStep && isPending ? (
|
||||
<Button
|
||||
isLoading={approveAction.isPending}
|
||||
onClick={() =>
|
||||
approveAction.mutate({ id: approval.request.id, values: { remark } })
|
||||
}
|
||||
>
|
||||
<Icons.circleCheck className='mr-2 h-4 w-4' />
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject && canHandleCurrentStep && isPending ? (
|
||||
<Button
|
||||
variant='destructive'
|
||||
isLoading={rejectAction.isPending}
|
||||
onClick={() =>
|
||||
rejectAction.mutate({ id: approval.request.id, values: { remark } })
|
||||
}
|
||||
>
|
||||
<Icons.warning className='mr-2 h-4 w-4' />
|
||||
Reject
|
||||
</Button>
|
||||
) : null}
|
||||
{canReturn && canHandleCurrentStep && isPending ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={returnAction.isPending}
|
||||
onClick={() =>
|
||||
returnAction.mutate({ id: approval.request.id, values: { remark } })
|
||||
}
|
||||
>
|
||||
<Icons.chevronLeft className='mr-2 h-4 w-4' />
|
||||
Return
|
||||
</Button>
|
||||
) : null}
|
||||
{canCancelCurrentRequest ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={cancelAction.isPending}
|
||||
onClick={() => cancelAction.mutate(approval.request.id)}
|
||||
>
|
||||
<Icons.close className='mr-2 h-4 w-4' />
|
||||
Cancel Request
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{!canHandleCurrentStep && isPending ? (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Current step is assigned to{' '}
|
||||
<span className='font-medium'>{approval.currentStep?.roleName ?? 'another role'}</span>.
|
||||
</div>
|
||||
) : null}
|
||||
{!isPending ? (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
This request is already {approval.request.status.replaceAll('_', ' ')}.
|
||||
</div>
|
||||
) : null}
|
||||
{isActing ? (
|
||||
<div className='text-muted-foreground text-xs'>Saving approval action...</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardDescription>Full audit trail of request submission and approval actions.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{!approval.timeline.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No approval actions recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
approval.timeline.map((item, index) => (
|
||||
<div key={item.id} className='space-y-4'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
||||
<div className='space-y-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{item.action}
|
||||
</Badge>
|
||||
<span className='text-sm font-medium'>
|
||||
{item.actorName ?? item.actedBy}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
Step {item.stepNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{new Date(item.actedAt).toLocaleString()}
|
||||
</div>
|
||||
{item.remark ? <div className='text-sm'>{item.remark}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{index < approval.timeline.length - 1 ? <Separator /> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export function ApprovalStatusBadge({ status }: { status: string }) {
|
||||
const normalized = status.toLowerCase();
|
||||
const variant =
|
||||
normalized === 'approved'
|
||||
? 'default'
|
||||
: normalized === 'pending'
|
||||
? 'secondary'
|
||||
: normalized === 'rejected'
|
||||
? 'destructive'
|
||||
: 'outline';
|
||||
const Icon =
|
||||
normalized === 'approved'
|
||||
? Icons.circleCheck
|
||||
: normalized === 'rejected'
|
||||
? Icons.warning
|
||||
: normalized === 'returned'
|
||||
? Icons.chevronLeft
|
||||
: Icons.clock;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className='capitalize'>
|
||||
<Icon className='h-3 w-3' />
|
||||
{status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { approvalsQueryOptions } from '../queries';
|
||||
import { getApprovalColumns } from './approval-columns';
|
||||
|
||||
export function ApprovalsTable() {
|
||||
const columns = useMemo(() => getApprovalColumns(), []);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
entityType: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.entityType && { entityType: params.entityType }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
const { data } = useSuspenseQuery(approvalsQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
84
src/features/foundation/approval/mutations.ts
Normal file
84
src/features/foundation/approval/mutations.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
approveApproval,
|
||||
cancelApproval,
|
||||
rejectApproval,
|
||||
returnApproval,
|
||||
submitApproval,
|
||||
submitQuotationForApproval
|
||||
} from './service';
|
||||
import { approvalKeys } from './queries';
|
||||
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||
import type { ApprovalActionPayload, SubmitApprovalPayload } from './types';
|
||||
|
||||
function invalidateQuotationQueries(quotationId: string) {
|
||||
const queryClient = getQueryClient();
|
||||
queryClient.invalidateQueries({ queryKey: quotationKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
|
||||
}
|
||||
|
||||
function invalidateRelatedEntityQueries(approvalId: string) {
|
||||
const queryClient = getQueryClient();
|
||||
const cachedDetail = queryClient.getQueryData<{ approval: { request: { entityType: string; entityId: string } } }>(
|
||||
approvalKeys.detail(approvalId)
|
||||
);
|
||||
|
||||
if (cachedDetail?.approval.request.entityType === 'quotation') {
|
||||
invalidateQuotationQueries(cachedDetail.approval.request.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
export const submitApprovalMutation = mutationOptions({
|
||||
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const submitQuotationApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
invalidateQuotationQueries(variables.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const cancelApprovalMutation = mutationOptions({
|
||||
mutationFn: (id: string) => cancelApproval(id),
|
||||
onSuccess: (_data, id) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
||||
invalidateRelatedEntityQueries(id);
|
||||
}
|
||||
});
|
||||
|
||||
export const approveApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
approveApproval(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||
invalidateRelatedEntityQueries(variables.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const rejectApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
rejectApproval(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||
invalidateRelatedEntityQueries(variables.id);
|
||||
}
|
||||
});
|
||||
|
||||
export const returnApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
returnApproval(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||
invalidateRelatedEntityQueries(variables.id);
|
||||
}
|
||||
});
|
||||
21
src/features/foundation/approval/queries.ts
Normal file
21
src/features/foundation/approval/queries.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getApprovalById, getApprovals } from './service';
|
||||
import type { ApprovalFilters } from './types';
|
||||
|
||||
export const approvalKeys = {
|
||||
all: ['crm-approvals'] as const,
|
||||
list: (filters: ApprovalFilters) => [...approvalKeys.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...approvalKeys.all, 'detail', id] as const
|
||||
};
|
||||
|
||||
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||
queryOptions({
|
||||
queryKey: approvalKeys.list(filters),
|
||||
queryFn: () => getApprovals(filters)
|
||||
});
|
||||
|
||||
export const approvalByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: approvalKeys.detail(id),
|
||||
queryFn: () => getApprovalById(id)
|
||||
});
|
||||
986
src/features/foundation/approval/server/service.ts
Normal file
986
src/features/foundation/approval/server/service.ts
Normal file
@@ -0,0 +1,986 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
type SQL
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmApprovalActions,
|
||||
crmApprovalRequests,
|
||||
crmApprovalSteps,
|
||||
crmApprovalWorkflows,
|
||||
crmCustomers,
|
||||
crmQuotationItems,
|
||||
crmQuotations,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
ApprovalActionRecord,
|
||||
ApprovalDetailRecord,
|
||||
ApprovalFilters,
|
||||
ApprovalListItem,
|
||||
ApprovalRequestRecord,
|
||||
ApprovalStepRecord,
|
||||
ApprovalTimelineItem,
|
||||
ApprovalWorkflowRecord,
|
||||
SubmitApprovalPayload
|
||||
} from '../types';
|
||||
|
||||
const APPROVAL_REQUEST_STATUSES = {
|
||||
pending: 'pending',
|
||||
approved: 'approved',
|
||||
rejected: 'rejected',
|
||||
returned: 'returned',
|
||||
cancelled: 'cancelled'
|
||||
} as const;
|
||||
|
||||
const APPROVAL_ACTIONS = {
|
||||
submit: 'submit',
|
||||
approve: 'approve',
|
||||
reject: 'reject',
|
||||
return: 'return',
|
||||
cancel: 'cancel'
|
||||
} as const;
|
||||
|
||||
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
|
||||
|
||||
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
entityType: row.entityType,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
workflowId: row.workflowId,
|
||||
stepNumber: row.stepNumber,
|
||||
roleCode: row.roleCode,
|
||||
roleName: row.roleName,
|
||||
isRequired: row.isRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): ApprovalRequestRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
workflowId: row.workflowId,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
currentStep: row.currentStep,
|
||||
status: row.status,
|
||||
requestedBy: row.requestedBy,
|
||||
requestedAt: row.requestedAt.toISOString(),
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapActionRecord(row: typeof crmApprovalActions.$inferSelect): ApprovalActionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
approvalRequestId: row.approvalRequestId,
|
||||
stepNumber: row.stepNumber,
|
||||
action: row.action,
|
||||
remark: row.remark,
|
||||
actedBy: row.actedBy,
|
||||
actedAt: row.actedAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function parseSort(sort?: string) {
|
||||
if (!sort) {
|
||||
return desc(crmApprovalRequests.updatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) {
|
||||
return desc(crmApprovalRequests.updatedAt);
|
||||
}
|
||||
|
||||
const sortMap = {
|
||||
status: crmApprovalRequests.status,
|
||||
currentStep: crmApprovalRequests.currentStep,
|
||||
requestedAt: crmApprovalRequests.requestedAt,
|
||||
updatedAt: crmApprovalRequests.updatedAt
|
||||
} as const;
|
||||
|
||||
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||
if (!column) {
|
||||
return desc(crmApprovalRequests.updatedAt);
|
||||
}
|
||||
|
||||
return rule.desc ? desc(column) : asc(column);
|
||||
} catch {
|
||||
return desc(crmApprovalRequests.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveQuotationStatusIdByCode(organizationId: string, code: string) {
|
||||
const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId });
|
||||
return options.find((option) => option.code === code)?.id ?? null;
|
||||
}
|
||||
|
||||
async function resolveQuotationStatusCodeById(organizationId: string, id: string) {
|
||||
const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId });
|
||||
return options.find((option) => option.id === id)?.code ?? null;
|
||||
}
|
||||
|
||||
async function assertWorkflowByCode(
|
||||
organizationId: string,
|
||||
code: string,
|
||||
entityType?: string
|
||||
) {
|
||||
const [workflow] = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
eq(crmApprovalWorkflows.code, code),
|
||||
eq(crmApprovalWorkflows.isActive, true),
|
||||
isNull(crmApprovalWorkflows.deletedAt),
|
||||
...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : [])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!workflow) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function assertApprovalRequest(id: string, organizationId: string) {
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(crmApprovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalRequests.id, id),
|
||||
eq(crmApprovalRequests.organizationId, organizationId),
|
||||
isNull(crmApprovalRequests.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!request) {
|
||||
throw new AuthError('Approval request not found', 404);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
async function listWorkflowSteps(
|
||||
workflowId: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalStepRecord[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.workflowId, workflowId),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmApprovalSteps.stepNumber));
|
||||
|
||||
return rows.map(mapStepRecord);
|
||||
}
|
||||
|
||||
async function assertActivePendingRequestForEntity(
|
||||
organizationId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
) {
|
||||
const [request] = await db
|
||||
.select()
|
||||
.from(crmApprovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalRequests.organizationId, organizationId),
|
||||
eq(crmApprovalRequests.entityType, entityType),
|
||||
eq(crmApprovalRequests.entityId, entityId),
|
||||
eq(crmApprovalRequests.status, APPROVAL_REQUEST_STATUSES.pending),
|
||||
isNull(crmApprovalRequests.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return request ?? null;
|
||||
}
|
||||
|
||||
async function assertQuotationForApprovalReadiness(entityId: string, organizationId: string) {
|
||||
const [quotation] = await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.id, entityId),
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!quotation) {
|
||||
throw new AuthError('Quotation not found', 404);
|
||||
}
|
||||
|
||||
const statusCode = await resolveQuotationStatusCodeById(organizationId, quotation.status);
|
||||
if (!statusCode || !['draft', 'revised'].includes(statusCode)) {
|
||||
throw new AuthError('Only draft or revised quotations can be submitted for approval', 400);
|
||||
}
|
||||
|
||||
const [itemCount] = await db
|
||||
.select({ value: count() })
|
||||
.from(crmQuotationItems)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationItems.organizationId, organizationId),
|
||||
eq(crmQuotationItems.quotationId, entityId),
|
||||
isNull(crmQuotationItems.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (!itemCount?.value) {
|
||||
throw new AuthError('Quotation must contain at least one item before approval', 400);
|
||||
}
|
||||
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.id, quotation.customerId),
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
isNull(crmCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!customer) {
|
||||
throw new AuthError('Quotation must be linked to an active customer', 400);
|
||||
}
|
||||
|
||||
return quotation;
|
||||
}
|
||||
|
||||
async function syncQuotationStatusFromApproval(
|
||||
quotationId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
nextStatusCode: string
|
||||
) {
|
||||
const statusId = await resolveQuotationStatusIdByCode(organizationId, nextStatusCode);
|
||||
|
||||
if (!statusId) {
|
||||
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
status: statusId,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmQuotations.id, quotationId));
|
||||
}
|
||||
|
||||
async function syncEntityStatusFromApproval(
|
||||
organizationId: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
userId: string,
|
||||
approvalStatus: string
|
||||
) {
|
||||
if (entityType !== 'quotation') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.pending) {
|
||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval');
|
||||
return;
|
||||
}
|
||||
|
||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) {
|
||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
|
||||
return;
|
||||
}
|
||||
|
||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.rejected) {
|
||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (approvalStatus === APPROVAL_REQUEST_STATUSES.returned) {
|
||||
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'draft');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertActorCanHandleStep(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
roleCode: string
|
||||
) {
|
||||
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
|
||||
if (!userRow) {
|
||||
throw new AuthError('User not found', 404);
|
||||
}
|
||||
|
||||
if (userRow.systemRole === 'super_admin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
.select()
|
||||
.from(memberships)
|
||||
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
throw new AuthError('Organization membership required', 403);
|
||||
}
|
||||
|
||||
if (membership.role === 'admin' || membership.businessRole === roleCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AuthError('You are not allowed to act on this approval step', 403);
|
||||
}
|
||||
|
||||
async function resolveEntitySummaries(
|
||||
organizationId: string,
|
||||
items: Array<{ entityType: string; entityId: string }>
|
||||
) {
|
||||
const quotationIds = items
|
||||
.filter((item) => item.entityType === 'quotation')
|
||||
.map((item) => item.entityId);
|
||||
const quotations = quotationIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: crmQuotations.id,
|
||||
code: crmQuotations.code,
|
||||
projectName: crmQuotations.projectName
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
inArray(crmQuotations.id, quotationIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
return new Map(
|
||||
quotations.map((quotation) => [
|
||||
`quotation:${quotation.id}`,
|
||||
{
|
||||
entityCode: quotation.code,
|
||||
entityTitle: quotation.projectName ?? quotation.code
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function buildApprovalFilters(organizationId: string, filters: ApprovalFilters): SQL[] {
|
||||
return [
|
||||
eq(crmApprovalRequests.organizationId, organizationId),
|
||||
isNull(crmApprovalRequests.deletedAt),
|
||||
...(filters.status ? [eq(crmApprovalRequests.status, filters.status)] : []),
|
||||
...(filters.entityType ? [eq(crmApprovalRequests.entityType, filters.entityType)] : []),
|
||||
...(filters.entityId ? [eq(crmApprovalRequests.entityId, filters.entityId)] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
ilike(crmApprovalRequests.entityId, `%${filters.search}%`),
|
||||
ilike(crmApprovalRequests.status, `%${filters.search}%`)
|
||||
)!
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
export async function listApprovalRequests(
|
||||
organizationId: string,
|
||||
filters: ApprovalFilters
|
||||
): Promise<{ items: ApprovalListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildApprovalFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalRequests)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
|
||||
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
|
||||
const [workflows, requesters, entitySummaries] = await Promise.all([
|
||||
workflowIds.length
|
||||
? db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(inArray(crmApprovalWorkflows.id, workflowIds))
|
||||
: [],
|
||||
requesterIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, requesterIds)) : [],
|
||||
resolveEntitySummaries(
|
||||
organizationId,
|
||||
rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId }))
|
||||
)
|
||||
]);
|
||||
const workflowMap = new Map(workflows.map((row) => [row.id, row]));
|
||||
const requesterMap = new Map(requesters.map((row) => [row.id, row.name]));
|
||||
const stepMap = new Map<string, ApprovalStepRecord>();
|
||||
|
||||
if (workflowIds.length) {
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
inArray(crmApprovalSteps.workflowId, workflowIds),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
for (const step of steps) {
|
||||
stepMap.set(`${step.workflowId}:${step.stepNumber}`, mapStepRecord(step));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => {
|
||||
const workflow = workflowMap.get(row.workflowId);
|
||||
const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null;
|
||||
const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null;
|
||||
|
||||
return {
|
||||
...mapRequestRecord(row),
|
||||
workflowCode: workflow?.code ?? '',
|
||||
workflowName: workflow?.name ?? '',
|
||||
currentStepRoleCode: currentStep?.roleCode ?? null,
|
||||
currentStepRoleName: currentStep?.roleName ?? null,
|
||||
requestedByName: requesterMap.get(row.requestedBy) ?? null,
|
||||
entityCode: entitySummary?.entityCode ?? null,
|
||||
entityTitle: entitySummary?.entityTitle ?? null
|
||||
};
|
||||
}),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getApprovalTimeline(
|
||||
approvalRequestId: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalTimelineItem[]> {
|
||||
await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalActions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalActions.approvalRequestId, approvalRequestId),
|
||||
eq(crmApprovalActions.organizationId, organizationId),
|
||||
isNull(crmApprovalActions.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt));
|
||||
const actorIds = [...new Set(rows.map((row) => row.actedBy))];
|
||||
const actors = actorIds.length
|
||||
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, actorIds))
|
||||
: [];
|
||||
const actorMap = new Map(actors.map((row) => [row.id, row.name]));
|
||||
|
||||
return rows.map((row) => ({
|
||||
...mapActionRecord(row),
|
||||
actorName: actorMap.get(row.actedBy) ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCurrentApprovalStep(
|
||||
approvalRequestId: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalStepRecord | null> {
|
||||
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.workflowId, request.workflowId),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
eq(crmApprovalSteps.stepNumber, request.currentStep),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ? mapStepRecord(row) : null;
|
||||
}
|
||||
|
||||
export async function getApprovalRequest(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalDetailRecord> {
|
||||
const requestRow = await assertApprovalRequest(id, organizationId);
|
||||
const [workflowRow] = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(eq(crmApprovalWorkflows.id, requestRow.workflowId))
|
||||
.limit(1);
|
||||
|
||||
if (!workflowRow) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
|
||||
const [steps, timeline, entitySummaries] = await Promise.all([
|
||||
listWorkflowSteps(requestRow.workflowId, organizationId),
|
||||
getApprovalTimeline(id, organizationId),
|
||||
resolveEntitySummaries(organizationId, [
|
||||
{ entityType: requestRow.entityType, entityId: requestRow.entityId }
|
||||
])
|
||||
]);
|
||||
const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null;
|
||||
const entitySummary =
|
||||
entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null;
|
||||
|
||||
return {
|
||||
request: mapRequestRecord(requestRow),
|
||||
workflow: mapWorkflowRecord(workflowRow),
|
||||
steps,
|
||||
currentStep,
|
||||
timeline,
|
||||
entityCode: entitySummary?.entityCode ?? null,
|
||||
entityTitle: entitySummary?.entityTitle ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export async function submitForApproval(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: SubmitApprovalPayload
|
||||
) {
|
||||
const workflow = await assertWorkflowByCode(
|
||||
organizationId,
|
||||
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
|
||||
payload.entityType
|
||||
);
|
||||
const steps = await listWorkflowSteps(workflow.id, organizationId);
|
||||
|
||||
if (!steps.length) {
|
||||
throw new AuthError('Approval workflow has no steps', 400);
|
||||
}
|
||||
|
||||
const existingRequest = await assertActivePendingRequestForEntity(
|
||||
organizationId,
|
||||
payload.entityType,
|
||||
payload.entityId
|
||||
);
|
||||
|
||||
if (existingRequest) {
|
||||
throw new AuthError('This document already has a pending approval request', 400);
|
||||
}
|
||||
|
||||
if (payload.entityType === 'quotation') {
|
||||
await assertQuotationForApprovalReadiness(payload.entityId, organizationId);
|
||||
}
|
||||
|
||||
const [createdRequest] = await db
|
||||
.insert(crmApprovalRequests)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
workflowId: workflow.id,
|
||||
entityType: payload.entityType,
|
||||
entityId: payload.entityId,
|
||||
currentStep: steps[0].stepNumber,
|
||||
status: APPROVAL_REQUEST_STATUSES.pending,
|
||||
requestedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [createdAction] = await db
|
||||
.insert(crmApprovalActions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
approvalRequestId: createdRequest.id,
|
||||
stepNumber: 0,
|
||||
action: APPROVAL_ACTIONS.submit,
|
||||
remark: payload.remark?.trim() || null,
|
||||
actedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await syncEntityStatusFromApproval(
|
||||
organizationId,
|
||||
payload.entityType,
|
||||
payload.entityId,
|
||||
userId,
|
||||
APPROVAL_REQUEST_STATUSES.pending
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: createdRequest.id,
|
||||
action: APPROVAL_ACTIONS.submit,
|
||||
afterData: createdRequest
|
||||
}),
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_action',
|
||||
entityId: createdAction.id,
|
||||
action: APPROVAL_ACTIONS.submit,
|
||||
afterData: createdAction
|
||||
})
|
||||
]);
|
||||
|
||||
return createdRequest;
|
||||
}
|
||||
|
||||
export async function approveApproval(
|
||||
approvalRequestId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
remark?: string
|
||||
) {
|
||||
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
|
||||
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||
throw new AuthError('Only pending approvals can be approved', 400);
|
||||
}
|
||||
|
||||
const steps = await listWorkflowSteps(request.workflowId, organizationId);
|
||||
const currentStep = steps.find((step) => step.stepNumber === request.currentStep);
|
||||
|
||||
if (!currentStep) {
|
||||
throw new AuthError('Current approval step not found', 404);
|
||||
}
|
||||
|
||||
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||
const nextStep = steps.find((step) => step.stepNumber > currentStep.stepNumber) ?? null;
|
||||
|
||||
const [createdAction] = await db
|
||||
.insert(crmApprovalActions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
approvalRequestId,
|
||||
stepNumber: currentStep.stepNumber,
|
||||
action: APPROVAL_ACTIONS.approve,
|
||||
remark: remark?.trim() || null,
|
||||
actedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [updatedRequest] = await db
|
||||
.update(crmApprovalRequests)
|
||||
.set({
|
||||
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
|
||||
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
|
||||
completedAt: nextStep ? null : new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||
.returning();
|
||||
|
||||
if (!nextStep) {
|
||||
await syncEntityStatusFromApproval(
|
||||
organizationId,
|
||||
request.entityType,
|
||||
request.entityId,
|
||||
userId,
|
||||
APPROVAL_REQUEST_STATUSES.approved
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: approvalRequestId,
|
||||
action: APPROVAL_ACTIONS.approve,
|
||||
beforeData: request,
|
||||
afterData: updatedRequest
|
||||
}),
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_action',
|
||||
entityId: createdAction.id,
|
||||
action: APPROVAL_ACTIONS.approve,
|
||||
afterData: createdAction
|
||||
})
|
||||
]);
|
||||
|
||||
return updatedRequest;
|
||||
}
|
||||
|
||||
export async function rejectApproval(
|
||||
approvalRequestId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
remark?: string
|
||||
) {
|
||||
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
|
||||
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||
throw new AuthError('Only pending approvals can be rejected', 400);
|
||||
}
|
||||
|
||||
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||
|
||||
if (!currentStep) {
|
||||
throw new AuthError('Current approval step not found', 404);
|
||||
}
|
||||
|
||||
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||
|
||||
const [createdAction] = await db
|
||||
.insert(crmApprovalActions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
approvalRequestId,
|
||||
stepNumber: currentStep.stepNumber,
|
||||
action: APPROVAL_ACTIONS.reject,
|
||||
remark: remark?.trim() || null,
|
||||
actedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [updatedRequest] = await db
|
||||
.update(crmApprovalRequests)
|
||||
.set({
|
||||
status: APPROVAL_REQUEST_STATUSES.rejected,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||
.returning();
|
||||
|
||||
await syncEntityStatusFromApproval(
|
||||
organizationId,
|
||||
request.entityType,
|
||||
request.entityId,
|
||||
userId,
|
||||
APPROVAL_REQUEST_STATUSES.rejected
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: approvalRequestId,
|
||||
action: APPROVAL_ACTIONS.reject,
|
||||
beforeData: request,
|
||||
afterData: updatedRequest
|
||||
}),
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_action',
|
||||
entityId: createdAction.id,
|
||||
action: APPROVAL_ACTIONS.reject,
|
||||
afterData: createdAction
|
||||
})
|
||||
]);
|
||||
|
||||
return updatedRequest;
|
||||
}
|
||||
|
||||
export async function returnApproval(
|
||||
approvalRequestId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
remark?: string
|
||||
) {
|
||||
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
|
||||
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||
throw new AuthError('Only pending approvals can be returned', 400);
|
||||
}
|
||||
|
||||
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||
|
||||
if (!currentStep) {
|
||||
throw new AuthError('Current approval step not found', 404);
|
||||
}
|
||||
|
||||
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||
|
||||
const [createdAction] = await db
|
||||
.insert(crmApprovalActions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
approvalRequestId,
|
||||
stepNumber: currentStep.stepNumber,
|
||||
action: APPROVAL_ACTIONS.return,
|
||||
remark: remark?.trim() || null,
|
||||
actedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [updatedRequest] = await db
|
||||
.update(crmApprovalRequests)
|
||||
.set({
|
||||
status: APPROVAL_REQUEST_STATUSES.returned,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||
.returning();
|
||||
|
||||
await syncEntityStatusFromApproval(
|
||||
organizationId,
|
||||
request.entityType,
|
||||
request.entityId,
|
||||
userId,
|
||||
APPROVAL_REQUEST_STATUSES.returned
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: approvalRequestId,
|
||||
action: APPROVAL_ACTIONS.return,
|
||||
beforeData: request,
|
||||
afterData: updatedRequest
|
||||
}),
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_action',
|
||||
entityId: createdAction.id,
|
||||
action: APPROVAL_ACTIONS.return,
|
||||
afterData: createdAction
|
||||
})
|
||||
]);
|
||||
|
||||
return updatedRequest;
|
||||
}
|
||||
|
||||
export async function cancelApproval(
|
||||
approvalRequestId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) {
|
||||
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||
|
||||
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||
throw new AuthError('Only pending approvals can be cancelled', 400);
|
||||
}
|
||||
|
||||
if (request.requestedBy !== userId) {
|
||||
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
|
||||
}
|
||||
|
||||
const [createdAction] = await db
|
||||
.insert(crmApprovalActions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
approvalRequestId,
|
||||
stepNumber: request.currentStep,
|
||||
action: APPROVAL_ACTIONS.cancel,
|
||||
remark: null,
|
||||
actedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [updatedRequest] = await db
|
||||
.update(crmApprovalRequests)
|
||||
.set({
|
||||
status: APPROVAL_REQUEST_STATUSES.cancelled,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||
.returning();
|
||||
|
||||
await syncEntityStatusFromApproval(
|
||||
organizationId,
|
||||
request.entityType,
|
||||
request.entityId,
|
||||
userId,
|
||||
APPROVAL_REQUEST_STATUSES.returned
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_request',
|
||||
entityId: approvalRequestId,
|
||||
action: APPROVAL_ACTIONS.cancel,
|
||||
beforeData: request,
|
||||
afterData: updatedRequest
|
||||
}),
|
||||
auditAction({
|
||||
organizationId,
|
||||
userId,
|
||||
entityType: 'crm_approval_action',
|
||||
entityId: createdAction.id,
|
||||
action: APPROVAL_ACTIONS.cancel,
|
||||
afterData: createdAction
|
||||
})
|
||||
]);
|
||||
|
||||
return updatedRequest;
|
||||
}
|
||||
69
src/features/foundation/approval/service.ts
Normal file
69
src/features/foundation/approval/service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ApprovalDetailResponse,
|
||||
ApprovalFilters,
|
||||
ApprovalListResponse,
|
||||
MutationSuccessResponse,
|
||||
SubmitApprovalPayload
|
||||
} from './types';
|
||||
|
||||
export async function getApprovals(filters: ApprovalFilters): Promise<ApprovalListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.entityType) searchParams.set('entityType', filters.entityType);
|
||||
if (filters.entityId) searchParams.set('entityId', filters.entityId);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<ApprovalListResponse>(`/crm/approvals${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getApprovalById(id: string): Promise<ApprovalDetailResponse> {
|
||||
return apiClient<ApprovalDetailResponse>(`/crm/approvals/${id}`);
|
||||
}
|
||||
|
||||
export async function submitApproval(data: SubmitApprovalPayload) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/approvals', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelApproval(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveApproval(id: string, data: ApprovalActionPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectApproval(id: string, data: ApprovalActionPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnApproval(id: string, data: ApprovalActionPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/return`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitQuotationForApproval(id: string, remark?: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/submit-approval`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark })
|
||||
});
|
||||
}
|
||||
121
src/features/foundation/approval/types.ts
Normal file
121
src/features/foundation/approval/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export interface ApprovalWorkflowRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalStepRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
workflowId: string;
|
||||
stepNumber: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
isRequired: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
workflowId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
currentStep: number;
|
||||
status: string;
|
||||
requestedBy: string;
|
||||
requestedAt: string;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalActionRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
approvalRequestId: string;
|
||||
stepNumber: number;
|
||||
action: string;
|
||||
remark: string | null;
|
||||
actedBy: string;
|
||||
actedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalListItem extends ApprovalRequestRecord {
|
||||
workflowCode: string;
|
||||
workflowName: string;
|
||||
currentStepRoleCode: string | null;
|
||||
currentStepRoleName: string | null;
|
||||
requestedByName: string | null;
|
||||
entityCode: string | null;
|
||||
entityTitle: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalTimelineItem extends ApprovalActionRecord {
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalDetailRecord {
|
||||
request: ApprovalRequestRecord;
|
||||
workflow: ApprovalWorkflowRecord;
|
||||
steps: ApprovalStepRecord[];
|
||||
currentStep: ApprovalStepRecord | null;
|
||||
timeline: ApprovalTimelineItem[];
|
||||
entityCode: string | null;
|
||||
entityTitle: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface SubmitApprovalPayload {
|
||||
workflowCode?: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalActionPayload {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
totalItems: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ApprovalListItem[];
|
||||
}
|
||||
|
||||
export interface ApprovalDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
approval: ApprovalDetailRecord;
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
Reference in New Issue
Block a user