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