This commit is contained in:
phaichayon
2026-06-16 10:57:41 +07:00
parent dff22d75b5
commit 357414c247
33 changed files with 9281 additions and 26 deletions

View File

@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getDocumentTemplate,
softDeleteDocumentTemplate,
updateDocumentTemplate
} from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const documentTemplateSchema = z.object({
documentType: z.string().min(1).optional(),
productType: z.string().min(1).optional(),
fileType: z.string().min(1).optional(),
templateName: z.string().min(1).optional(),
description: z.string().optional().nullable(),
isDefault: z.boolean().optional(),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateRead
});
const template = await getDocumentTemplate(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document template loaded successfully',
template
});
} 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 document template' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateUpdate
});
const payload = documentTemplateSchema.parse(await request.json());
const before = await getDocumentTemplate(id, organization.id);
const updated = await updateDocumentTemplate(id, organization.id, session.user.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document template updated 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 update document template' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateDelete
});
const before = await getDocumentTemplate(id, organization.id);
const updated = await softDeleteDocumentTemplate(id, organization.id, session.user.id);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document template deleted 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 delete document template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createDocumentTemplateVersion,
listDocumentTemplateVersions
} from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const versionSchema = z.object({
version: z.string().min(1),
filePath: z.string().optional().nullable(),
schemaJson: z.unknown(),
previewImageUrl: z.string().optional().nullable(),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateRead
});
const items = await listDocumentTemplateVersions(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document template versions loaded successfully',
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 document template versions' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateUpdate
});
const payload = versionSchema.parse(await request.json());
const created = await createDocumentTemplateVersion(id, organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_version',
entityId: created.id,
afterData: created
});
return NextResponse.json({
success: true,
message: 'Document template version created 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 create document template version' }, { status: 500 });
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createDocumentTemplate,
listDocumentTemplates
} from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const documentTemplateSchema = z.object({
documentType: z.string().min(1),
productType: z.string().min(1),
fileType: z.string().min(1),
templateName: z.string().min(1),
description: z.string().optional().nullable(),
isDefault: z.boolean().optional(),
isActive: z.boolean().optional()
});
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateRead
});
const { searchParams } = request.nextUrl;
const items = await listDocumentTemplates(organization.id, {
documentType: searchParams.get('documentType') ?? undefined,
fileType: searchParams.get('fileType') ?? undefined,
isActive: searchParams.get('isActive') ?? undefined
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document templates loaded successfully',
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 document templates' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateCreate
});
const payload = documentTemplateSchema.parse(await request.json());
const created = await createDocumentTemplate(organization.id, session.user.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template',
entityId: created.id,
afterData: created
});
return NextResponse.json({
success: true,
message: 'Document template created 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 create document template' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildQuotationDocumentData } from '@/features/crm/quotations/document/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.crmQuotationDocumentPreview
});
const documentData = await buildQuotationDocumentData(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation document data loaded successfully',
documentData
});
} 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 quotation document data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/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.crmQuotationDocumentPreview
});
const preview = await getQuotationDocumentPreviewData(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Quotation document preview loaded successfully',
preview
});
} 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 quotation document preview' }, { status: 500 });
}
}

View File

@@ -1,6 +1,7 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
import {
quotationAttachmentsOptions,
quotationByIdOptions,
@@ -85,6 +86,11 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
const isOrgAdmin =
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
const canPreviewDocument =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDocumentPreview)));
const queryClient = getQueryClient();
if (canRead) {
@@ -102,6 +108,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
limit: 20
})
);
if (canPreviewDocument) {
void queryClient.prefetchQuery(quotationDocumentPreviewOptions(id));
}
}
const referenceData =
@@ -139,6 +148,7 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
activeBusinessRole={session?.user?.activeBusinessRole ?? null}
isOrgAdmin={isOrgAdmin}
currentUserId={session?.user?.id ?? ''}
canPreviewDocument={canPreviewDocument}
/>
</HydrationBoundary>
) : null}

View File

@@ -1,19 +1,42 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
import { TemplateSettings } from '@/features/foundation/document-template/components/template-settings';
import { documentTemplatesQueryOptions } from '@/features/foundation/document-template/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
export default async function TemplatesRoute() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmDocumentTemplateRead)));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
);
}
export default function TemplatesRoute() {
return (
<PageContainer
pageTitle='Quotation Templates'
pageDescription='Template management is not part of the production path yet and no longer depends on legacy demo services.'
pageDescription='Template metadata, versions, and mapping counts for production quotation documents.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM document templates.
</div>
}
>
<CrmProductionPlaceholder
title='Template management pending'
summary='The placeholder quotation template demo has been isolated away from the production CRM route tree.'
foundationItems={['Master options foundation', 'Sequence foundation', 'Audit foundation']}
nextStep='Introduce template persistence only when the production quotation/PDF flow is designed.'
demoHref='/dashboard/crm-demo/settings/templates'
/>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<TemplateSettings />
</HydrationBoundary>
) : null}
</PageContainer>
);
}