This commit is contained in:
phaichayon
2026-06-16 09:19:17 +07:00
parent b8f13e36b3
commit dff22d75b5
32 changed files with 5786 additions and 68 deletions

View 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()
}
];
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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);
}
});

View 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)
});

View 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;
}

View 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 })
});
}

View 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;
}