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,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { approveApproval } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const actionSchema = z.object({
remark: z.string().optional()
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalApprove
});
const payload = actionSchema.parse(await request.json());
await approveApproval(id, organization.id, session.user.id, payload.remark);
return NextResponse.json({
success: true,
message: 'Approval completed for current step'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to approve request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { rejectApproval } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const actionSchema = z.object({
remark: z.string().optional()
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalReject
});
const payload = actionSchema.parse(await request.json());
await rejectApproval(id, organization.id, session.user.id, payload.remark);
return NextResponse.json({
success: true,
message: 'Approval request rejected'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reject approval request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { returnApproval } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const actionSchema = z.object({
remark: z.string().optional()
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalReturn
});
const payload = actionSchema.parse(await request.json());
await returnApproval(id, organization.id, session.user.id, payload.remark);
return NextResponse.json({
success: true,
message: 'Approval request returned for change'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to return approval request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
});
const approval = await getApprovalRequest(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval detail loaded successfully',
approval
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval detail' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalSubmit
});
await cancelApproval(id, organization.id, session.user.id);
return NextResponse.json({
success: true,
message: 'Approval request cancelled successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to cancel approval request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const submitApprovalSchema = z.object({
workflowCode: z.string().optional(),
entityType: z.string().min(1, 'Entity type is required'),
entityId: z.string().min(1, 'Entity id is required'),
remark: z.string().optional()
});
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const status = searchParams.get('status') ?? undefined;
const entityType = searchParams.get('entityType') ?? undefined;
const entityId = searchParams.get('entityId') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listApprovalRequests(organization.id, {
page,
limit,
search,
status,
entityType,
entityId,
sort
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approvals loaded successfully',
totalItems: result.totalItems,
offset: (page - 1) * limit,
limit,
items: result.items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approvals' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalSubmit
});
const payload = submitApprovalSchema.parse(await request.json());
const created = await submitForApproval(organization.id, session.user.id, payload);
return NextResponse.json(
{
success: true,
message: 'Approval request submitted successfully',
approvalRequest: created
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to submit approval request' }, { status: 500 });
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { submitForApproval } from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const submitSchema = z.object({
remark: z.string().optional()
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalSubmit
});
const payload = submitSchema.parse(await request.json());
await submitForApproval(organization.id, session.user.id, {
workflowCode: 'quotation_standard_approval',
entityType: 'quotation',
entityId: id,
remark: payload.remark
});
return NextResponse.json({
success: true,
message: 'Quotation submitted for approval successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to submit quotation for approval' }, { status: 500 });
}
}

View File

@@ -0,0 +1,76 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail';
import { approvalByIdOptions } from '@/features/foundation/approval/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function ApprovalDetailRoute({ params }: PageProps) {
const { id } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalRead)));
const canApprove =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove)));
const canReject =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject)));
const canReturn =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
const canCancel =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
const isOrgAdmin =
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(approvalByIdOptions(id));
}
return (
<PageContainer
pageTitle='Approval Detail'
pageDescription='Workflow steps, current approver, and timeline for a production approval request.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this approval request.
</div>
}
>
{canRead && session?.user ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<ApprovalDetail
approvalId={id}
canApprove={canApprove}
canReject={canReject}
canReturn={canReturn}
canCancel={canCancel}
activeBusinessRole={session.user.activeBusinessRole}
isOrgAdmin={isOrgAdmin}
currentUserId={session.user.id}
/>
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -1,28 +1,41 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
import ApprovalListing from '@/features/foundation/approval/components/approval-listing';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: CRM Approvals'
};
export default function ApprovalsRoute() {
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function ApprovalsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalRead)));
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Approvals'
pageDescription='Production approval workflow has not started yet. This route no longer imports demo approval state.'
pageDescription='Production approval queue for quotations, with real workflow routing and action history.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM approvals.
</div>
}
>
<CrmProductionPlaceholder
title='Approval workflow pending'
summary='The old pending approvals mock list now lives only under crm-demo.'
foundationItems={[
'Permission checks',
'Business-role-aware access helpers',
'Branch validation',
'Audit log helper'
]}
nextStep='Implement production approval workflow only after quotation persistence and routing are in place.'
demoHref='/dashboard/crm-demo/approvals'
/>
{canRead ? <ApprovalListing /> : null}
</PageContainer>
);
}

View File

@@ -12,6 +12,7 @@ import {
} from '@/features/crm/quotations/api/queries';
import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail';
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
import { approvalsQueryOptions } from '@/features/foundation/approval/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -62,6 +63,28 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate)));
const canSubmitApproval =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
const canApproveApproval =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove)));
const canRejectApproval =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject)));
const canReturnApproval =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
const isOrgAdmin =
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
const queryClient = getQueryClient();
if (canRead) {
@@ -72,6 +95,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
void queryClient.prefetchQuery(quotationFollowupsOptions(id));
void queryClient.prefetchQuery(quotationAttachmentsOptions(id));
void queryClient.prefetchQuery(quotationRevisionsOptions(id));
void queryClient.prefetchQuery(
approvalsQueryOptions({
entityType: 'quotation',
entityId: id,
limit: 20
})
);
}
const referenceData =
@@ -102,6 +132,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
canManageFollowups={canManageFollowups}
canManageAttachments={canManageAttachments}
canCreateRevision={canCreateRevision}
canSubmitApproval={canSubmitApproval}
canApproveApproval={canApproveApproval}
canRejectApproval={canRejectApproval}
canReturnApproval={canReturnApproval}
activeBusinessRole={session?.user?.activeBusinessRole ?? null}
isOrgAdmin={isOrgAdmin}
currentUserId={session?.user?.id ?? ''}
/>
</HydrationBoundary>
) : null}

View File

@@ -113,7 +113,7 @@ export const navGroups: NavGroup[] = [
icon: 'checks',
isActive: false,
items: [],
access: { requireOrg: true, role: 'admin' }
access: { requireOrg: true, permission: 'crm.approval.read' }
},
{
title: 'CRM Settings',

View File

@@ -392,3 +392,76 @@ export const crmQuotationAttachments = pgTable('crm_quotation_attachments', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmApprovalWorkflows = pgTable(
'crm_approval_workflows',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
code: text('code').notNull(),
name: text('name').notNull(),
entityType: text('entity_type').notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
},
(table) => ({
organizationCodeIdx: uniqueIndex('crm_approval_workflows_org_code_idx').on(
table.organizationId,
table.code
)
})
);
export const crmApprovalSteps = pgTable(
'crm_approval_steps',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
workflowId: text('workflow_id').notNull(),
stepNumber: integer('step_number').notNull(),
roleCode: text('role_code').notNull(),
roleName: text('role_name').notNull(),
isRequired: boolean('is_required').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
},
(table) => ({
workflowStepIdx: uniqueIndex('crm_approval_steps_workflow_step_idx').on(
table.workflowId,
table.stepNumber
)
})
);
export const crmApprovalRequests = pgTable('crm_approval_requests', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
workflowId: text('workflow_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
currentStep: integer('current_step').default(1).notNull(),
status: text('status').notNull(),
requestedBy: text('requested_by').notNull(),
requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmApprovalActions = pgTable('crm_approval_actions', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
approvalRequestId: text('approval_request_id').notNull(),
stepNumber: integer('step_number').notNull(),
action: text('action').notNull(),
remark: text('remark'),
actedBy: text('acted_by').notNull(),
actedAt: timestamp('acted_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});

View File

@@ -200,6 +200,19 @@ const DOCUMENT_SEQUENCES = [
{ documentType: 'approval', prefix: 'APV' }
];
const APPROVAL_WORKFLOWS = [
{
code: 'quotation_standard_approval',
name: 'Quotation Standard Approval',
entityType: 'quotation',
steps: [
{ stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' },
{ stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' },
{ stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' }
]
}
];
async function upsertMasterOptionsForOrganization(
sql: SqlClient,
organizationId: string
@@ -306,6 +319,69 @@ async function upsertDocumentSequencesForOrganization(
}
}
async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizationId: string) {
for (const workflow of APPROVAL_WORKFLOWS) {
const [workflowRow] = await sql`
insert into crm_approval_workflows (
id,
organization_id,
code,
name,
entity_type,
is_active,
deleted_at
) values (
${crypto.randomUUID()},
${organizationId},
${workflow.code},
${workflow.name},
${workflow.entityType},
${true},
${null}
)
on conflict (organization_id, code) do update
set
name = excluded.name,
entity_type = excluded.entity_type,
is_active = excluded.is_active,
deleted_at = excluded.deleted_at,
updated_at = now()
returning id
`;
for (const step of workflow.steps) {
await sql`
insert into crm_approval_steps (
id,
organization_id,
workflow_id,
step_number,
role_code,
role_name,
is_required,
deleted_at
) values (
${crypto.randomUUID()},
${organizationId},
${workflowRow.id},
${step.stepNumber},
${step.roleCode},
${step.roleName},
${true},
${null}
)
on conflict (workflow_id, step_number) do update
set
role_code = excluded.role_code,
role_name = excluded.role_name,
is_required = excluded.is_required,
deleted_at = excluded.deleted_at,
updated_at = now()
`;
}
}
}
async function main() {
loadLocalEnv();
@@ -328,6 +404,7 @@ async function main() {
for (const organization of organizations) {
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
}
} finally {

View File

@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import {
approvalByIdOptions,
approvalsQueryOptions
} from '@/features/foundation/approval/queries';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { ApprovalRequestPanel } from '@/features/foundation/approval/components/approval-request-panel';
import { ApprovalStatusBadge } from '@/features/foundation/approval/components/approval-status-badge';
export function QuotationApprovalTab({
quotationId,
quotationStatusCode,
canSubmitApproval,
canApproveApproval,
canRejectApproval,
canReturnApproval,
activeBusinessRole,
isOrgAdmin,
currentUserId
}: {
quotationId: string;
quotationStatusCode: string | null;
canSubmitApproval: boolean;
canApproveApproval: boolean;
canRejectApproval: boolean;
canReturnApproval: boolean;
activeBusinessRole: string | null;
isOrgAdmin: boolean;
currentUserId: string;
}) {
const [remark, setRemark] = useState('');
const { data: requestsData } = useSuspenseQuery(
approvalsQueryOptions({
entityType: 'quotation',
entityId: quotationId,
limit: 20
})
);
const latestRequest = requestsData.items[0] ?? null;
const { data: approvalDetailData } = useQuery({
...approvalByIdOptions(latestRequest?.id ?? ''),
enabled: !!latestRequest
});
const submitApproval = useMutation({
...submitQuotationApprovalMutation,
onSuccess: () => {
toast.success('Quotation submitted for approval');
setRemark('');
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Unable to submit quotation')
});
const canSubmitNow = canSubmitApproval && ['draft', 'revised'].includes(quotationStatusCode ?? '');
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Current Status</CardTitle>
<CardDescription>Submission readiness and latest approval request state.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex flex-wrap items-center gap-3'>
<ApprovalStatusBadge status={latestRequest?.status ?? quotationStatusCode ?? 'draft'} />
<span className='text-muted-foreground text-sm'>
{latestRequest
? `Latest request: ${latestRequest.workflowName}`
: 'No approval request has been submitted yet.'}
</span>
</div>
{canSubmitNow ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='text-sm font-medium'>Submit quotation into approval flow</div>
<Textarea
value={remark}
onChange={(event) => setRemark(event.target.value)}
placeholder='Optional remark for approvers'
rows={3}
/>
<Button
isLoading={submitApproval.isPending}
onClick={() => submitApproval.mutate({ id: quotationId, remark })}
>
<Icons.send className='mr-2 h-4 w-4' />
Submit for Approval
</Button>
</div>
) : null}
</CardContent>
</Card>
{approvalDetailData?.approval ? (
<ApprovalRequestPanel
approval={approvalDetailData.approval}
canApprove={canApproveApproval}
canReject={canRejectApproval}
canReturn={canReturnApproval}
canCancel={canSubmitApproval}
activeBusinessRole={activeBusinessRole}
isOrgAdmin={isOrgAdmin}
currentUserId={currentUserId}
/>
) : (
<Card>
<CardHeader>
<CardTitle>Approval Timeline</CardTitle>
<CardDescription>Submission and approver history will appear here.</CardDescription>
</CardHeader>
<CardContent>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No approval activity recorded yet.
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -40,7 +40,6 @@ import {
deleteQuotationFollowupMutation,
deleteQuotationItemMutation,
deleteQuotationTopicMutation,
updateQuotationMutation,
updateQuotationAttachmentMutation,
updateQuotationCustomerMutation,
updateQuotationFollowupMutation,
@@ -69,7 +68,9 @@ import type {
QuotationTopicMutationPayload,
QuotationTopicRecord
} from '../api/types';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { QuotationFormSheet } from './quotation-form-sheet';
import { QuotationApprovalTab } from './quotation-approval-tab';
import { QuotationStatusBadge } from './quotation-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
@@ -473,7 +474,14 @@ export function QuotationDetail({
canManageTopics,
canManageFollowups,
canManageAttachments,
canCreateRevision
canCreateRevision,
canSubmitApproval,
canApproveApproval,
canRejectApproval,
canReturnApproval,
activeBusinessRole,
isOrgAdmin,
currentUserId
}: {
quotationId: string;
referenceData: QuotationReferenceData;
@@ -484,6 +492,13 @@ export function QuotationDetail({
canManageFollowups: boolean;
canManageAttachments: boolean;
canCreateRevision: boolean;
canSubmitApproval: boolean;
canApproveApproval: boolean;
canRejectApproval: boolean;
canReturnApproval: boolean;
activeBusinessRole: string | null;
isOrgAdmin: boolean;
currentUserId: string;
}) {
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
@@ -611,7 +626,7 @@ export function QuotationDetail({
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
});
const submitForApproval = useMutation({
...updateQuotationMutation,
...submitQuotationApprovalMutation,
onSuccess: () => toast.success('Quotation moved to pending approval'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
@@ -655,7 +670,8 @@ export function QuotationDetail({
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
status?.code ?? ''
);
const canSubmitApproval = ['draft', 'revised'].includes(status?.code ?? '');
const canSubmitCurrentQuotation =
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
return (
<div className='space-y-6'>
@@ -814,44 +830,11 @@ export function QuotationDetail({
</div>
</div>
<div className='flex flex-wrap gap-2'>
{canUpdate && canSubmitApproval ? (
{canSubmitCurrentQuotation ? (
<Button
variant='outline'
isLoading={submitForApproval.isPending}
onClick={() =>
submitForApproval.mutate({
id: quotationId,
values: {
enquiryId: quotation.enquiryId,
customerId: quotation.customerId,
contactId: quotation.contactId,
quotationDate: quotation.quotationDate,
validUntil: quotation.validUntil,
quotationType: quotation.quotationType,
projectName: quotation.projectName ?? '',
projectLocation: quotation.projectLocation ?? '',
attention: quotation.attention ?? '',
branchId: quotation.branchId,
currency: quotation.currency,
exchangeRate: quotation.exchangeRate,
status:
referenceData.statuses.find((item) => item.code === 'pending_approval')?.id ??
quotation.status,
chancePercent: quotation.chancePercent,
isHotProject: quotation.isHotProject,
competitor: quotation.competitor ?? '',
reference: quotation.reference ?? '',
notes: quotation.notes ?? '',
salesmanId: quotation.salesmanId,
discount: quotation.discount,
discountType: quotation.discountType,
taxRate: quotation.taxRate,
sentVia: quotation.sentVia,
revisionRemark: quotation.revisionRemark,
isActive: quotation.isActive
}
})
}
onClick={() => submitForApproval.mutate({ id: quotationId })}
>
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
</Button>
@@ -1191,15 +1174,17 @@ export function QuotationDetail({
</TabsContent>
<TabsContent value='approval'>
<Card>
<CardHeader>
<CardTitle>Approval Placeholder</CardTitle>
<CardDescription>Approval workflow is deferred beyond Task E.</CardDescription>
</CardHeader>
<CardContent>
<EmptyState message='Approval state and approver routing will land in a later task.' />
</CardContent>
</Card>
<QuotationApprovalTab
quotationId={quotationId}
quotationStatusCode={status?.code ?? null}
canSubmitApproval={canSubmitApproval}
canApproveApproval={canApproveApproval}
canRejectApproval={canRejectApproval}
canReturnApproval={canReturnApproval}
activeBusinessRole={activeBusinessRole}
isOrgAdmin={isOrgAdmin}
currentUserId={currentUserId}
/>
</TabsContent>
<TabsContent value='preview'>

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

View File

@@ -6,7 +6,10 @@ export const BUSINESS_ROLES = [
'infrastructure',
'application',
'auditor',
'viewer'
'viewer',
'sales_manager',
'department_manager',
'top_manager'
] as const;
export type SystemRole = (typeof SYSTEM_ROLES)[number];
@@ -44,7 +47,12 @@ export const PERMISSIONS = {
crmQuotationTopicManage: 'crm.quotation.topic.manage',
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
crmQuotationRevisionCreate: 'crm.quotation.revision.create'
crmQuotationRevisionCreate: 'crm.quotation.revision.create',
crmApprovalRead: 'crm.approval.read',
crmApprovalSubmit: 'crm.approval.submit',
crmApprovalApprove: 'crm.approval.approve',
crmApprovalReject: 'crm.approval.reject',
crmApprovalReturn: 'crm.approval.return'
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -81,7 +89,12 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn
];
}
@@ -91,7 +104,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead
];
}
@@ -108,6 +122,9 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
case 'auditor':
return [PERMISSIONS.reportRead];
case 'viewer':
case 'sales_manager':
case 'department_manager':
case 'top_manager':
default:
return [];
}

View File

@@ -25,6 +25,7 @@ export const searchParams = {
hot: parseAsString,
view: parseAsString,
status: parseAsString,
entityType: parseAsString,
assetType: parseAsString,
role: parseAsString,
sort: parseAsString