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,125 @@
# Task G - Quotation Document Preview Foundation
## Files Added
- `src/features/foundation/document-template/types.ts`
- `src/features/foundation/document-template/service.ts`
- `src/features/foundation/document-template/queries.ts`
- `src/features/foundation/document-template/mutations.ts`
- `src/features/foundation/document-template/server/service.ts`
- `src/features/foundation/document-template/components/template-settings.tsx`
- `src/features/crm/quotations/document/types.ts`
- `src/features/crm/quotations/document/service.ts`
- `src/features/crm/quotations/document/queries.ts`
- `src/features/crm/quotations/document/server/service.ts`
- `src/features/crm/quotations/components/quotation-document-preview.tsx`
- `src/app/api/crm/quotations/[id]/document-data/route.ts`
- `src/app/api/crm/quotations/[id]/document-preview/route.ts`
- `src/app/api/crm/document-templates/route.ts`
- `src/app/api/crm/document-templates/[id]/route.ts`
- `src/app/api/crm/document-templates/[id]/versions/route.ts`
- `drizzle/0006_worried_mordo.sql`
## Files Modified
- `src/db/schema.ts`
- `src/db/seeds/foundation.seed.ts`
- `src/lib/auth/rbac.ts`
- `src/config/nav-config.ts`
- `src/app/dashboard/crm/settings/templates/page.tsx`
- `src/app/dashboard/crm/quotations/[id]/page.tsx`
- `src/features/crm/quotations/components/quotation-detail.tsx`
- `src/features/crm/quotations/api/mutations.ts`
- `src/features/foundation/approval/mutations.ts`
- `docs/implementation/technical-debt.md`
## Schema Added
- `crm_document_templates`
- `crm_document_template_versions`
- `crm_document_template_mappings`
- `crm_document_template_table_columns`
## API Routes Added
- `GET /api/crm/quotations/[id]/document-data`
- `GET /api/crm/quotations/[id]/document-preview`
- `GET/POST /api/crm/document-templates`
- `GET/PATCH/DELETE /api/crm/document-templates/[id]`
- `GET/POST /api/crm/document-templates/[id]/versions`
## Preview UI Added
- Replaced quotation `Document Preview` placeholder with a production preview tab backed by real quotation data, template resolution, and mapped preview payloads.
- Added watermark/status behavior for non-approved quotations through normalized `watermarkStatus`.
- Preview now shows:
- quotation header
- customer block
- project info
- items table
- price summary
- scope / exclusion / payment topics
- approval block
- signature placeholders
## Template Settings UI Added
- Replaced `/dashboard/crm/settings/templates` placeholder with a production listing page.
- Current UI is intentionally lightweight and read-focused:
- template name and description
- default badge
- active/inactive badge
- file type
- latest version
- version count
- mapping count
## Template Seed Added
- Foundation seed now upserts a default quotation `pdfme` template per organization.
- Seed picks template JSON by organization name:
- `ALLA_template_pdfme_fainal3.json`
- `ONVALLA_template_pdfme_fainal3.json`
- If the organization name does not match either branch, the seed falls back to a minimal schema payload.
- Seed also creates baseline placeholder mappings and a table mapping for quotation line items.
## Mapping Resolver Added
- `resolveTemplateForDocument()`
- `resolveTemplateMappings()`
- `mapDocumentDataToTemplateInput()`
Behavior:
- resolves by `documentType + productType + fileType`
- falls back to `default` product type when no specific product template exists
- maps dot-path source fields into scalar, multiline, or table-ready template input
## Approved Snapshot Prep Added
- `prepareApprovedQuotationSnapshot(quotationId, organizationId)`
Output contains:
- `quotationId`
- `approvedAt`
- `documentData`
- `templateVersionId`
- `templateInput`
## Remaining Risks
- Mapping CRUD UI is not implemented yet, so template mappings remain seed-driven and read-only.
- `pdfme` preview is not canvas-accurate rendering; current preview is an application-native document view using the normalized document snapshot.
- Template resolution currently uses `productType: default` for quotation preview. Product-specific template selection can be added later once the business rule is finalized.
- No binary PDF generation, storage, or approved PDF URL persistence yet.
## Task H Readiness
Task G leaves the repo ready for:
- actual `pdfme` generation service integration
- approved PDF snapshot persistence
- template version publishing workflow
- mapping editor or admin maintenance UI
- product-specific template resolution rules

View File

@@ -140,3 +140,53 @@ Future:
Priority: Priority:
* High * High
## After Task F
### Approval Permission Sync
Existing memberships may not automatically receive new approval permissions.
Future:
- add permission sync script
- admin UI for role/permission management
### Approval Workflow Config UI
Approval workflow is seeded but not configurable from UI.
Future:
- workflow setup screen
- step editor
- approver role mapping
### Approval Notification
Approval workflow has no email/line notification yet.
Future:
- notification queue
- approval reminder
## After Task G
### Document Mapping Editor
Template mappings are seeded and readable, but not yet manageable from UI.
Future:
- mapping CRUD UI
- table column editor
- publish/draft template workflow
### pdfme Render Parity
Document preview is application-native, not pixel-perfect `pdfme` rendering.
Future:
- server-side pdfme render pipeline
- preview image generation
- schema validation against live template versions
### Approved PDF Persistence
Approved snapshot preparation exists, but no binary file generation or storage write-back yet.
Future:
- approved PDF generator
- storage abstraction
- immutable approved artifact URL on quotation

View File

@@ -0,0 +1,66 @@
CREATE TABLE "crm_document_template_mappings" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"template_version_id" text NOT NULL,
"placeholder_key" text NOT NULL,
"source_path" text NOT NULL,
"data_type" text NOT NULL,
"sheet_name" text,
"default_value" text,
"format_mask" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_document_template_table_columns" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"mapping_id" text NOT NULL,
"column_name" text NOT NULL,
"source_field" text NOT NULL,
"column_letter" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"format_mask" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_document_template_versions" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"template_id" text NOT NULL,
"version" text NOT NULL,
"file_path" text,
"schema_json" jsonb NOT NULL,
"preview_image_url" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "crm_document_templates" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"document_type" text NOT NULL,
"product_type" text NOT NULL,
"file_type" text NOT NULL,
"template_name" text NOT NULL,
"description" text,
"is_default" boolean DEFAULT false NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"created_by" text NOT NULL,
"updated_by" text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_mappings_version_placeholder_idx" ON "crm_document_template_mappings" USING btree ("template_version_id","placeholder_key");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_table_columns_mapping_name_idx" ON "crm_document_template_table_columns" USING btree ("mapping_id","column_name");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_template_versions_template_version_idx" ON "crm_document_template_versions" USING btree ("template_id","version");--> statement-breakpoint
CREATE UNIQUE INDEX "crm_document_templates_org_doc_product_file_name_idx" ON "crm_document_templates" USING btree ("organization_id","document_type","product_type","file_type","template_name");

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1781575976324, "when": 1781575976324,
"tag": "0005_numerous_blob", "tag": "0005_numerous_blob",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1781579746622,
"tag": "0006_worried_mordo",
"breakpoints": true
} }
] ]
} }

509
plans/task-g.md Normal file
View File

@@ -0,0 +1,509 @@
# Task G: Quotation Document Preview / PDF Template Foundation
## ALLA OS CRM vNext
คุณคือ Senior Full-stack Engineer
ให้ implement Quotation Document Preview และ PDF Template Foundation โดยต่อยอดจาก:
```txt
Task A
Task B
Task B.1
Task C
Task D
Task E
Task E.1
Task F
```
## ต้องอ่านก่อน
```txt
docs/implementation/task-a-template-audit.md
docs/implementation/task-b-template-audit.md
docs/implementation/task-b1-foundation-stabilization.md
docs/implementation/technical-debt.md
docs/adr/0007-quotation-revision-strategy.md
docs/adr/0008-attachment-storage-strategy.md
Layout.md
AGENTS.md
.agents/skills/kiranism-shadcn-dashboard
src/features/foundation/**
src/features/crm/customers/**
src/features/crm/enquiries/**
src/features/crm/quotations/**
src/db/schema.ts
```
---
# Goal
สร้าง foundation สำหรับเอกสารใบเสนอราคา
โฟกัสที่:
```txt
Quotation document preview
PDF template metadata
Template versioning
Template placeholder mapping
Approved snapshot preparation
```
ยังไม่ทำ PDF generation จริงแบบ production เต็ม
---
# Scope G1: Template Schema
เพิ่ม tables ถ้ายังไม่มี
```txt
crm_document_templates
crm_document_template_versions
crm_document_template_mappings
crm_document_template_table_columns
```
## crm_document_templates
```txt
id
organizationId
documentType
productType
fileType
templateName
description
isDefault
isActive
createdAt
updatedAt
deletedAt
createdBy
updatedBy
```
documentType example:
```txt
quotation
```
fileType example:
```txt
pdfme
excel
html
```
---
## crm_document_template_versions
```txt
id
organizationId
templateId
version
filePath
schemaJson
previewImageUrl
isActive
createdAt
updatedAt
deletedAt
createdBy
```
---
## crm_document_template_mappings
```txt
id
organizationId
templateVersionId
placeholderKey
sourcePath
dataType
sheetName
defaultValue
formatMask
sortOrder
createdAt
updatedAt
deletedAt
```
dataType:
```txt
scalar
multiline
table
image
```
---
## crm_document_template_table_columns
```txt
id
organizationId
mappingId
columnName
sourceField
columnLetter
sortOrder
formatMask
createdAt
updatedAt
deletedAt
```
---
# Scope G2: Quotation Snapshot Preparation
เพิ่มใน quotation document service
สร้าง function:
```ts
buildQuotationDocumentData(quotationId)
```
ให้ output เป็น normalized object สำหรับ preview/document generation
ต้องรวม:
```txt
company
branch
customer
contact
quotation
items
quotationCustomers
topics
approval
signatures placeholder
```
ตัวอย่าง output:
```ts
{
company: {},
branch: {},
customer: {},
contact: {},
quotation: {},
items: [],
topics: {
scope: [],
exclusion: [],
payment: []
},
approval: {
status,
approvedAt,
approvers: []
}
}
```
Rules:
* validate organizationId
* ห้าม query ข้าม organization
* ใช้ approved quotation ได้
* draft/pending preview ได้ แต่ต้องแสดง watermark/status badge ใน UI
---
# Scope G3: Template Mapping Resolver
สร้าง service:
```txt
src/features/foundation/document-template/**
```
หรือถ้าเหมาะสม:
```txt
src/features/crm/quotations/document/**
```
Functions:
```ts
resolveTemplateForDocument(params)
resolveTemplateMappings(templateVersionId)
mapDocumentDataToTemplateInput(documentData, mappings)
```
Rules:
* template default ตาม documentType + productType + fileType
* fallback default template ของ documentType
* mapping ต้องไม่ hardcode เฉพาะ quotation มากเกินไป
* sourcePath ใช้ dot path เช่น customer.name, quotation.code, items
---
# Scope G4: API Routes
เพิ่ม route handlers:
```txt
src/app/api/crm/quotations/[id]/document-data/route.ts
src/app/api/crm/quotations/[id]/document-preview/route.ts
src/app/api/crm/document-templates/route.ts
src/app/api/crm/document-templates/[id]/route.ts
src/app/api/crm/document-templates/[id]/versions/route.ts
```
รองรับ:
```txt
GET quotation document data
GET quotation document preview data
GET/POST document templates
GET/PATCH/DELETE document template
GET/POST template versions
```
ยังไม่ต้องทำ binary PDF download
---
# Scope G5: Preview UI
ใน:
```txt
/dashboard/crm/quotations/[id]
```
แทน Document Preview Placeholder ด้วย real preview tab
Preview ต้องแสดง:
```txt
quotation header
customer block
project info
items table
price summary
scope/exclusion/payment topics
approval block
signature placeholders
status watermark/badge
```
ใช้ Layout.md และ shadcn dashboard style
ห้ามสร้าง design system ใหม่
---
# Scope G6: Template Settings UI
เพิ่ม route ถ้ายังเป็น placeholder:
```txt
/dashboard/crm/settings/templates
```
UI ขั้นต่ำ:
```txt
Template list
Template versions
Default template badge
Active/inactive badge
Mapping count
```
ยังไม่ต้องทำ template designer แบบ drag/drop
ทำได้แค่ metadata CRUD หรือ read-only listing ก่อน
---
# Scope G7: pdfme Template Seed
ถ้ามี template json อยู่แล้ว ให้ seed เป็น default quotation pdfme template
Seed:
```txt
documentType = quotation
fileType = pdfme
productType = default
templateName = ALLA Quotation Standard
version = 1.0
schemaJson = existing pdfme template json
isDefault = true
isActive = true
```
Mapping placeholders:
```txt
customer_name -> customer.name
customer_addr -> customer.address
customer_tel -> customer.phone
customer_email -> customer.email
customer_att -> contact.name
project_name -> quotation.projectName
site_location -> quotation.projectLocation
quotation_date -> quotation.quotationDate
quotation_code -> quotation.code
quotation_price -> quotation.totalAmount
currency -> quotation.currency
exclusion_data -> topics.exclusion
item_topic -> topics.scope
```
ถ้าไม่มี template json ใน repo ให้สร้าง seed placeholder template schemaJson แบบ minimal
---
# Scope G8: Approved Snapshot Preparation
ยังไม่ต้อง generate PDF จริง
แต่ให้เตรียม service function:
```ts
prepareApprovedQuotationSnapshot(quotationId)
```
ใช้สำหรับอนาคตเมื่อ quotation approved แล้ว
Output:
```ts
{
quotationId,
approvedAt,
documentData,
templateVersionId,
templateInput
}
```
ยังไม่ต้องเขียนลง approvedPdfUrl ถ้ายังไม่มี PDF generator
---
# Permissions
เพิ่มถ้ายังไม่มี:
```txt
crm.document_template.read
crm.document_template.create
crm.document_template.update
crm.document_template.delete
crm.quotation.document.preview
```
---
# Audit
Audit mutation:
```txt
crm_document_template
crm_document_template_version
crm_document_template_mapping
```
Preview GET ไม่ต้อง audit
---
# ห้ามทำ
```txt
PDF binary generation
Excel binary generation
File upload storage
Drag/drop template designer
Report
Dashboard KPI
Notification
```
---
# Output
หลังทำเสร็จ สรุป:
1. Files Added
2. Files Modified
3. Schema Added
4. API Routes Added
5. Preview UI Added
6. Template Settings UI Added
7. Template Seed Added
8. Mapping Resolver Added
9. Approved Snapshot Prep Added
10. Remaining Risks
11. Task H Readiness
---
# Definition of Done
Task G ผ่านเมื่อ:
✔ document template schema พร้อม
✔ template version schema พร้อม
✔ mapping schema พร้อม
✔ quotation document data service พร้อม
✔ template resolver พร้อม
✔ quotation preview tab แสดงข้อมูลจริง
✔ template settings route ใช้งานได้ขั้นต่ำ
✔ pdfme default template seed พร้อม หรือ fallback minimal seed พร้อม
✔ approved snapshot preparation พร้อม
✔ ยังไม่ generate PDF binary จริง
✔ ยังไม่ทำ template designer
✔ ยังไม่ทำ report/dashboard

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 { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
import { import {
quotationAttachmentsOptions, quotationAttachmentsOptions,
quotationByIdOptions, quotationByIdOptions,
@@ -85,6 +86,11 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn))); session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
const isOrgAdmin = const isOrgAdmin =
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin'; 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(); const queryClient = getQueryClient();
if (canRead) { if (canRead) {
@@ -102,6 +108,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
limit: 20 limit: 20
}) })
); );
if (canPreviewDocument) {
void queryClient.prefetchQuery(quotationDocumentPreviewOptions(id));
}
} }
const referenceData = const referenceData =
@@ -139,6 +148,7 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
activeBusinessRole={session?.user?.activeBusinessRole ?? null} activeBusinessRole={session?.user?.activeBusinessRole ?? null}
isOrgAdmin={isOrgAdmin} isOrgAdmin={isOrgAdmin}
currentUserId={session?.user?.id ?? ''} currentUserId={session?.user?.id ?? ''}
canPreviewDocument={canPreviewDocument}
/> />
</HydrationBoundary> </HydrationBoundary>
) : null} ) : 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 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 ( return (
<PageContainer <PageContainer
pageTitle='Quotation Templates' 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 {canRead ? (
title='Template management pending' <HydrationBoundary state={dehydrate(queryClient)}>
summary='The placeholder quotation template demo has been isolated away from the production CRM route tree.' <TemplateSettings />
foundationItems={['Master options foundation', 'Sequence foundation', 'Audit foundation']} </HydrationBoundary>
nextStep='Introduce template persistence only when the production quotation/PDF flow is designed.' ) : null}
demoHref='/dashboard/crm-demo/settings/templates'
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -120,7 +120,7 @@ export const navGroups: NavGroup[] = [
url: '/dashboard/crm/settings/master-options', url: '/dashboard/crm/settings/master-options',
icon: 'settings', icon: 'settings',
isActive: false, isActive: false,
access: { requireOrg: true, permission: 'organization:manage' }, access: { requireOrg: true, permission: 'crm.document_template.read' },
items: [ items: [
{ {
title: 'Master Options', title: 'Master Options',
@@ -135,7 +135,7 @@ export const navGroups: NavGroup[] = [
{ {
title: 'Templates', title: 'Templates',
url: '/dashboard/crm/settings/templates', url: '/dashboard/crm/settings/templates',
access: { requireOrg: true, permission: 'organization:manage' } access: { requireOrg: true, permission: 'crm.document_template.read' }
} }
] ]
} }

View File

@@ -465,3 +465,104 @@ export const crmApprovalActions = pgTable('crm_approval_actions', {
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }) deletedAt: timestamp('deleted_at', { withTimezone: true })
}); });
export const crmDocumentTemplates = pgTable(
'crm_document_templates',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
documentType: text('document_type').notNull(),
productType: text('product_type').notNull(),
fileType: text('file_type').notNull(),
templateName: text('template_name').notNull(),
description: text('description'),
isDefault: boolean('is_default').default(false).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 }),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull()
},
(table) => ({
organizationTemplateIdx: uniqueIndex('crm_document_templates_org_doc_product_file_name_idx').on(
table.organizationId,
table.documentType,
table.productType,
table.fileType,
table.templateName
)
})
);
export const crmDocumentTemplateVersions = pgTable(
'crm_document_template_versions',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
templateId: text('template_id').notNull(),
version: text('version').notNull(),
filePath: text('file_path'),
schemaJson: jsonb('schema_json').notNull(),
previewImageUrl: text('preview_image_url'),
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 }),
createdBy: text('created_by').notNull()
},
(table) => ({
templateVersionIdx: uniqueIndex('crm_document_template_versions_template_version_idx').on(
table.templateId,
table.version
)
})
);
export const crmDocumentTemplateMappings = pgTable(
'crm_document_template_mappings',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
templateVersionId: text('template_version_id').notNull(),
placeholderKey: text('placeholder_key').notNull(),
sourcePath: text('source_path').notNull(),
dataType: text('data_type').notNull(),
sheetName: text('sheet_name'),
defaultValue: text('default_value'),
formatMask: text('format_mask'),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
},
(table) => ({
templatePlaceholderIdx: uniqueIndex('crm_document_template_mappings_version_placeholder_idx').on(
table.templateVersionId,
table.placeholderKey
)
})
);
export const crmDocumentTemplateTableColumns = pgTable(
'crm_document_template_table_columns',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
mappingId: text('mapping_id').notNull(),
columnName: text('column_name').notNull(),
sourceField: text('source_field').notNull(),
columnLetter: text('column_letter'),
sortOrder: integer('sort_order').default(0).notNull(),
formatMask: text('format_mask'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
},
(table) => ({
mappingColumnIdx: uniqueIndex('crm_document_template_table_columns_mapping_name_idx').on(
table.mappingId,
table.columnName
)
})
);

View File

@@ -18,6 +18,24 @@ type BranchSeedRow = {
label: string; label: string;
}; };
type TemplateColumnSeed = {
columnName: string;
sourceField: string;
columnLetter?: string;
sortOrder: number;
formatMask?: string | null;
};
type TemplateMappingSeed = {
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
defaultValue?: string | null;
formatMask?: string | null;
sortOrder: number;
columns?: TemplateColumnSeed[];
};
function loadEnvFile(filePath: string) { function loadEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return; return;
@@ -213,6 +231,145 @@ const APPROVAL_WORKFLOWS = [
} }
]; ];
const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
{
placeholderKey: 'customer_name',
sourcePath: 'customer.name',
dataType: 'scalar',
sortOrder: 1
},
{
placeholderKey: 'customer_addr',
sourcePath: 'customer.address',
dataType: 'multiline',
sortOrder: 2
},
{
placeholderKey: 'customer_tel',
sourcePath: 'customer.phone',
dataType: 'scalar',
sortOrder: 3
},
{
placeholderKey: 'customer_email',
sourcePath: 'customer.email',
dataType: 'scalar',
sortOrder: 4
},
{
placeholderKey: 'customer_att',
sourcePath: 'contact.name',
dataType: 'scalar',
sortOrder: 5
},
{
placeholderKey: 'project_name',
sourcePath: 'quotation.projectName',
dataType: 'scalar',
sortOrder: 6
},
{
placeholderKey: 'site_location',
sourcePath: 'quotation.projectLocation',
dataType: 'multiline',
sortOrder: 7
},
{
placeholderKey: 'quotation_date',
sourcePath: 'quotation.quotationDate',
dataType: 'scalar',
formatMask: 'date',
sortOrder: 8
},
{
placeholderKey: 'quotation_code',
sourcePath: 'quotation.code',
dataType: 'scalar',
sortOrder: 9
},
{
placeholderKey: 'quotation_price',
sourcePath: 'quotation.totalAmount',
dataType: 'scalar',
formatMask: 'currency',
sortOrder: 10
},
{
placeholderKey: 'currency',
sourcePath: 'quotation.currencyCode',
dataType: 'scalar',
sortOrder: 11
},
{
placeholderKey: 'exclusion_data',
sourcePath: 'topics.exclusion',
dataType: 'multiline',
sortOrder: 12
},
{
placeholderKey: 'item_topic',
sourcePath: 'topics.scope',
dataType: 'multiline',
sortOrder: 13
},
{
placeholderKey: 'items_table',
sourcePath: 'items',
dataType: 'table',
sortOrder: 14,
columns: [
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
{ columnName: 'Qty', sourceField: 'quantity', columnLetter: 'C', sortOrder: 3 },
{ columnName: 'Unit', sourceField: 'unitLabel', columnLetter: 'D', sortOrder: 4 },
{
columnName: 'Unit Price',
sourceField: 'unitPrice',
columnLetter: 'E',
sortOrder: 5,
formatMask: 'currency'
},
{
columnName: 'Total',
sourceField: 'totalPrice',
columnLetter: 'F',
sortOrder: 6,
formatMask: 'currency'
}
]
}
];
function loadTemplateSchemaByOrganizationName(organizationName: string) {
const normalized = organizationName.toUpperCase();
const fileName = normalized.includes('ONVALLA')
? 'ONVALLA_template_pdfme_fainal3.json'
: normalized.includes('ALLA')
? 'ALLA_template_pdfme_fainal3.json'
: null;
if (!fileName) {
return {
filePath: null,
templateName: `${organizationName} Quotation Standard`,
schemaJson: {
basePdf: '',
schemas: [],
pdfmeVersion: 'fallback'
}
};
}
const absolutePath = path.resolve(process.cwd(), 'src', 'pdfme_template', fileName);
const raw = fs.readFileSync(absolutePath, 'utf8');
return {
filePath: `src/pdfme_template/${fileName}`,
templateName: `${organizationName} Quotation Standard`,
schemaJson: JSON.parse(raw)
};
}
async function upsertMasterOptionsForOrganization( async function upsertMasterOptionsForOrganization(
sql: SqlClient, sql: SqlClient,
organizationId: string organizationId: string
@@ -382,6 +539,188 @@ async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizati
} }
} }
async function upsertDocumentTemplatesForOrganization(
sql: SqlClient,
organization: { id: string; name: string; createdBy: string }
) {
const templateSeed = loadTemplateSchemaByOrganizationName(organization.name);
const [templateRow] = await sql`
insert into crm_document_templates (
id,
organization_id,
document_type,
product_type,
file_type,
template_name,
description,
is_default,
is_active,
deleted_at,
created_by,
updated_by
) values (
${crypto.randomUUID()},
${organization.id},
${'quotation'},
${'default'},
${'pdfme'},
${templateSeed.templateName},
${'Seeded quotation template metadata for production preview foundation.'},
${true},
${true},
${null},
${organization.createdBy},
${organization.createdBy}
)
on conflict do nothing
returning id
`;
const resolvedTemplateId =
templateRow?.id ??
(
await sql`
select id
from crm_document_templates
where organization_id = ${organization.id}
and document_type = ${'quotation'}
and product_type = ${'default'}
and file_type = ${'pdfme'}
and deleted_at is null
order by is_default desc, created_at asc
limit 1
`
)[0]?.id;
if (!resolvedTemplateId) {
throw new Error(`Unable to resolve template id for organization ${organization.id}`);
}
const [versionRow] = await sql`
insert into crm_document_template_versions (
id,
organization_id,
template_id,
version,
file_path,
schema_json,
preview_image_url,
is_active,
deleted_at,
created_by
) values (
${crypto.randomUUID()},
${organization.id},
${resolvedTemplateId},
${'1.0'},
${templateSeed.filePath},
${JSON.stringify(templateSeed.schemaJson)},
${null},
${true},
${null},
${organization.createdBy}
)
on conflict do nothing
returning id
`;
const resolvedVersionId =
versionRow?.id ??
(
await sql`
select id
from crm_document_template_versions
where organization_id = ${organization.id}
and template_id = ${resolvedTemplateId}
and version = ${'1.0'}
and deleted_at is null
order by created_at asc
limit 1
`
)[0]?.id;
if (!resolvedVersionId) {
throw new Error(`Unable to resolve template version id for organization ${organization.id}`);
}
for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) {
const [mappingRow] = await sql`
insert into crm_document_template_mappings (
id,
organization_id,
template_version_id,
placeholder_key,
source_path,
data_type,
sheet_name,
default_value,
format_mask,
sort_order,
deleted_at
) values (
${crypto.randomUUID()},
${organization.id},
${resolvedVersionId},
${mapping.placeholderKey},
${mapping.sourcePath},
${mapping.dataType},
${null},
${mapping.defaultValue ?? null},
${mapping.formatMask ?? null},
${mapping.sortOrder},
${null}
)
on conflict do nothing
returning id
`;
const resolvedMappingId =
mappingRow?.id ??
(
await sql`
select id
from crm_document_template_mappings
where organization_id = ${organization.id}
and template_version_id = ${resolvedVersionId}
and placeholder_key = ${mapping.placeholderKey}
and deleted_at is null
limit 1
`
)[0]?.id;
if (!resolvedMappingId || !mapping.columns?.length) {
continue;
}
for (const column of mapping.columns) {
await sql`
insert into crm_document_template_table_columns (
id,
organization_id,
mapping_id,
column_name,
source_field,
column_letter,
sort_order,
format_mask,
deleted_at
) values (
${crypto.randomUUID()},
${organization.id},
${resolvedMappingId},
${column.columnName},
${column.sourceField},
${column.columnLetter ?? null},
${column.sortOrder},
${column.formatMask ?? null},
${null}
)
on conflict do nothing
`;
}
}
}
async function main() { async function main() {
loadLocalEnv(); loadLocalEnv();
@@ -390,7 +729,7 @@ async function main() {
try { try {
const organizations = await sql` const organizations = await sql`
select id, name select id, name, created_by as "createdBy"
from organizations from organizations
order by name asc order by name asc
`; `;
@@ -405,6 +744,7 @@ async function main() {
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
await upsertApprovalWorkflowsForOrganization(sql, organization.id); await upsertApprovalWorkflowsForOrganization(sql, organization.id);
await upsertDocumentTemplatesForOrganization(sql, organization);
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
} }
} finally { } finally {

View File

@@ -1,5 +1,6 @@
import { mutationOptions } from '@tanstack/react-query'; import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client'; import { getQueryClient } from '@/lib/query-client';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import { import {
createQuotation, createQuotation,
createQuotationAttachment, createQuotationAttachment,
@@ -31,6 +32,11 @@ import type {
QuotationTopicMutationPayload QuotationTopicMutationPayload
} from './types'; } from './types';
function invalidateQuotationDocumentQueries(quotationId: string) {
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
}
export const createQuotationMutation = mutationOptions({ export const createQuotationMutation = mutationOptions({
mutationFn: (data: QuotationMutationPayload) => createQuotation(data), mutationFn: (data: QuotationMutationPayload) => createQuotation(data),
onSuccess: () => { onSuccess: () => {
@@ -44,6 +50,7 @@ export const updateQuotationMutation = mutationOptions({
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) });
invalidateQuotationDocumentQueries(variables.id);
} }
}); });
@@ -61,6 +68,7 @@ export const createQuotationItemMutation = mutationOptions({
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -78,6 +86,7 @@ export const updateQuotationItemMutation = mutationOptions({
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -88,6 +97,7 @@ export const deleteQuotationItemMutation = mutationOptions({
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -104,6 +114,7 @@ export const createQuotationCustomerMutation = mutationOptions({
queryKey: quotationKeys.customers(variables.quotationId) queryKey: quotationKeys.customers(variables.quotationId)
}); });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -122,6 +133,7 @@ export const updateQuotationCustomerMutation = mutationOptions({
queryKey: quotationKeys.customers(variables.quotationId) queryKey: quotationKeys.customers(variables.quotationId)
}); });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -133,6 +145,7 @@ export const deleteQuotationCustomerMutation = mutationOptions({
queryKey: quotationKeys.customers(variables.quotationId) queryKey: quotationKeys.customers(variables.quotationId)
}); });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -141,6 +154,7 @@ export const createQuotationTopicMutation = mutationOptions({
createQuotationTopic(quotationId, values), createQuotationTopic(quotationId, values),
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -149,6 +163,7 @@ export const updateQuotationTopicMutation = mutationOptions({
updateQuotationTopic(quotationId, values), updateQuotationTopic(quotationId, values),
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });
@@ -157,6 +172,7 @@ export const deleteQuotationTopicMutation = mutationOptions({
deleteQuotationTopic(quotationId, topicId), deleteQuotationTopic(quotationId, topicId),
onSuccess: (_data, variables) => { onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
} }
}); });

View File

@@ -69,6 +69,7 @@ import type {
QuotationTopicRecord QuotationTopicRecord
} from '../api/types'; } from '../api/types';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { QuotationDocumentPreview } from './quotation-document-preview';
import { QuotationFormSheet } from './quotation-form-sheet'; import { QuotationFormSheet } from './quotation-form-sheet';
import { QuotationApprovalTab } from './quotation-approval-tab'; import { QuotationApprovalTab } from './quotation-approval-tab';
import { QuotationStatusBadge } from './quotation-status-badge'; import { QuotationStatusBadge } from './quotation-status-badge';
@@ -481,7 +482,8 @@ export function QuotationDetail({
canReturnApproval, canReturnApproval,
activeBusinessRole, activeBusinessRole,
isOrgAdmin, isOrgAdmin,
currentUserId currentUserId,
canPreviewDocument
}: { }: {
quotationId: string; quotationId: string;
referenceData: QuotationReferenceData; referenceData: QuotationReferenceData;
@@ -499,6 +501,7 @@ export function QuotationDetail({
activeBusinessRole: string | null; activeBusinessRole: string | null;
isOrgAdmin: boolean; isOrgAdmin: boolean;
currentUserId: string; currentUserId: string;
canPreviewDocument: boolean;
}) { }) {
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId)); const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
@@ -1188,15 +1191,19 @@ export function QuotationDetail({
</TabsContent> </TabsContent>
<TabsContent value='preview'> <TabsContent value='preview'>
{canPreviewDocument ? (
<QuotationDocumentPreview quotationId={quotationId} />
) : (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Document Preview Placeholder</CardTitle> <CardTitle>Document Preview</CardTitle>
<CardDescription>PDF and rendered output are intentionally postponed.</CardDescription> <CardDescription>Preview permission is required to load document data.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<EmptyState message='Production preview and PDF generation are outside Task E scope.' /> <EmptyState message='You do not have access to quotation document preview.' />
</CardContent> </CardContent>
</Card> </Card>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>

View File

@@ -0,0 +1,238 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries';
function Field({ 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 QuotationDocumentPreview({ quotationId }: { quotationId: string }) {
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
const { documentData, template } = data.preview;
return (
<div className='space-y-6'>
<Card className='overflow-hidden'>
<CardHeader className='border-b bg-muted/20'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-1'>
<CardTitle className='text-2xl'>{documentData.company.name}</CardTitle>
<CardDescription>
Quotation Preview using {template.template.templateName} v{template.version.version}
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{documentData.watermarkStatus ? (
<Badge variant='destructive'>{documentData.watermarkStatus}</Badge>
) : (
<Badge>Approved Ready</Badge>
)}
<Badge variant='outline'>{template.template.fileType}</Badge>
</div>
</div>
</CardHeader>
<CardContent className='space-y-8 p-6'>
<div className='grid gap-6 lg:grid-cols-2'>
<Card>
<CardHeader>
<CardTitle>Quotation Header</CardTitle>
<CardDescription>Commercial identity and issue information.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<Field label='Quotation Code' value={documentData.quotation.code} />
<Field label='Revision' value={documentData.quotation.revisionLabel} />
<Field
label='Quotation Date'
value={new Date(documentData.quotation.quotationDate).toLocaleDateString()}
/>
<Field
label='Valid Until'
value={
documentData.quotation.validUntil
? new Date(documentData.quotation.validUntil).toLocaleDateString()
: null
}
/>
<Field label='Type' value={documentData.quotation.quotationTypeLabel} />
<Field label='Branch' value={documentData.branch?.label} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Customer Block</CardTitle>
<CardDescription>Bill-to and attention context for the document.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<Field label='Customer' value={documentData.customer.name} />
<Field label='Contact' value={documentData.contact?.name} />
<Field label='Phone' value={documentData.customer.phone} />
<Field label='Email' value={documentData.customer.email} />
<div className='md:col-span-2'>
<Field label='Address' value={documentData.customer.address} />
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Project Information</CardTitle>
<CardDescription>Scope summary and reference data for the customer.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<Field label='Project Name' value={documentData.quotation.projectName} />
<Field label='Site Location' value={documentData.quotation.projectLocation} />
<Field label='Attention' value={documentData.quotation.attention} />
<Field label='Reference' value={documentData.quotation.reference} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Items Table</CardTitle>
<CardDescription>Normalized line items prepared for template mapping.</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<div className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border bg-muted/20 px-4 py-3 text-xs font-medium uppercase tracking-wide'>
<div>Item</div>
<div>Description</div>
<div>Qty</div>
<div>Unit</div>
<div>Unit Price</div>
<div>Total</div>
</div>
{documentData.items.map((item) => (
<div
key={item.id}
className='grid grid-cols-[1.2fr_4fr_1fr_1.2fr_1.4fr_1.4fr] gap-3 rounded-lg border px-4 py-3 text-sm'
>
<div>{item.itemNumber}</div>
<div className='space-y-1'>
<div>{item.description}</div>
<div className='text-muted-foreground text-xs'>{item.productTypeLabel || '-'}</div>
</div>
<div>{item.quantity}</div>
<div>{item.unitLabel || '-'}</div>
<div>{item.unitPrice.toLocaleString()}</div>
<div>{item.totalPrice.toLocaleString()}</div>
</div>
))}
</CardContent>
</Card>
<div className='grid gap-6 lg:grid-cols-[2fr_1fr]'>
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{[
{ key: 'scope', title: 'Scope' },
{ key: 'exclusion', title: 'Exclusions' },
{ key: 'payment', title: 'Payment Terms' }
].map((group) => {
const entries = documentData.topics[group.key as keyof typeof documentData.topics];
return (
<div key={group.key} className='space-y-3'>
<div className='font-medium'>{group.title}</div>
{!entries.length ? (
<div className='text-muted-foreground text-sm'>No entries</div>
) : (
entries.map((entry) => (
<div key={`${group.key}-${entry.title}`} className='rounded-lg border p-4'>
<div className='mb-2 text-sm font-medium'>{entry.title}</div>
<ul className='list-disc space-y-1 pl-5 text-sm'>
{entry.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
))
)}
</div>
);
})}
</CardContent>
</Card>
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Price Summary</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Subtotal</span>
<span>{documentData.quotation.subtotal.toLocaleString()}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Tax</span>
<span>{documentData.quotation.taxAmount.toLocaleString()}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>Total</span>
<span className='font-semibold'>{documentData.quotation.totalAmount.toLocaleString()}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Approval Block</CardTitle>
<CardDescription>Prepared for watermark and approval snapshot flows.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<Field label='Status' value={documentData.approval.status} />
<Field label='Workflow' value={documentData.approval.workflowName} />
<Field label='Approved At' value={documentData.approval.approvedAt} />
<Separator />
{!documentData.approval.approvers.length ? (
<div className='text-muted-foreground text-sm'>No approval actions recorded.</div>
) : (
documentData.approval.approvers.map((item) => (
<div key={`${item.stepNumber}-${item.roleCode}`} className='rounded-lg border p-3 text-sm'>
<div className='font-medium'>
Step {item.stepNumber} - {item.roleName}
</div>
<div className='text-muted-foreground'>
{item.actorName || '-'} {item.actedAt ? `on ${new Date(item.actedAt).toLocaleString()}` : ''}
</div>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Signature Placeholders</CardTitle>
</CardHeader>
<CardContent className='space-y-3 text-sm'>
<Field label='Prepared By' value={documentData.signatures.preparedBy} />
<Field label='Requested By' value={documentData.signatures.requestedBy} />
<Field label='Sales Manager' value={documentData.signatures.salesManager} />
<Field
label='Department Manager'
value={documentData.signatures.departmentManager}
/>
<Field label='Top Manager' value={documentData.signatures.topManager} />
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { queryOptions } from '@tanstack/react-query';
import { getQuotationDocumentData, getQuotationDocumentPreview } from './service';
export const quotationDocumentKeys = {
all: ['crm-quotation-document'] as const,
data: (id: string) => [...quotationDocumentKeys.all, 'data', id] as const,
preview: (id: string) => [...quotationDocumentKeys.all, 'preview', id] as const
};
export const quotationDocumentDataOptions = (id: string) =>
queryOptions({
queryKey: quotationDocumentKeys.data(id),
queryFn: () => getQuotationDocumentData(id)
});
export const quotationDocumentPreviewOptions = (id: string) =>
queryOptions({
queryKey: quotationDocumentKeys.preview(id),
queryFn: () => getQuotationDocumentPreview(id)
});

View File

@@ -0,0 +1,359 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import {
crmCustomerContacts,
crmCustomers,
crmQuotations,
organizations,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { listApprovalRequests, getApprovalRequest } from '@/features/foundation/approval/server/service';
import {
mapDocumentDataToTemplateInput,
resolveTemplateForDocument,
resolveTemplateMappings
} from '@/features/foundation/document-template/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
getQuotationDetail,
listQuotationCustomers,
listQuotationItems,
listQuotationTopics
} from '@/features/crm/quotations/server/service';
import type {
ApprovedQuotationSnapshot,
QuotationDocumentApprovalStep,
QuotationDocumentData
} from '../types';
const DOCUMENT_OPTION_CATEGORIES = {
branch: 'crm_branch',
status: 'crm_quotation_status',
quotationType: 'crm_quotation_type',
currency: 'crm_currency',
discountType: 'crm_discount_type',
unit: 'crm_unit',
customerRole: 'crm_quotation_customer_role',
productType: 'crm_product_type'
} as const;
function toRevisionLabel(revision: number) {
return `R${String(revision).padStart(2, '0')}`;
}
async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) {
const [quotation] = await db
.select()
.from(crmQuotations)
.where(
and(
eq(crmQuotations.id, quotationId),
eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt)
)
)
.limit(1);
if (!quotation) {
throw new AuthError('Quotation not found', 404);
}
return quotation;
}
function findOptionLabel(
options: Array<{ id: string; code: string; label: string }>,
id: string | null | undefined
) {
return id ? options.find((option) => option.id === id)?.label ?? null : null;
}
function findOptionCode(
options: Array<{ id: string; code: string; label: string }>,
id: string | null | undefined
) {
return id ? options.find((option) => option.id === id)?.code ?? null : null;
}
export async function buildQuotationDocumentData(
quotationId: string,
organizationId: string
): Promise<QuotationDocumentData> {
const quotation = await assertQuotationDocumentAccess(quotationId, organizationId);
const [
company,
quotationDetail,
items,
quotationCustomers,
topics,
branchOptions,
statusOptions,
quotationTypeOptions,
currencyOptions,
discountTypeOptions,
unitOptions,
customerRoleOptions,
productTypeOptions,
approvalRequests
] = await Promise.all([
db
.select()
.from(organizations)
.where(eq(organizations.id, organizationId))
.then((rows) => rows[0] ?? null),
getQuotationDetail(quotationId, organizationId),
listQuotationItems(quotationId, organizationId),
listQuotationCustomers(quotationId, organizationId),
listQuotationTopics(quotationId, organizationId),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { organizationId }),
listApprovalRequests(organizationId, { entityType: 'quotation', entityId: quotationId, limit: 20 })
]);
if (!company) {
throw new AuthError('Organization not found', 404);
}
const [customer, contact, relatedCustomers, preparer] = await Promise.all([
db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.id, quotation.customerId),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
)
)
.then((rows) => rows[0] ?? null),
quotation.contactId
? db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.id, quotation.contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
)
)
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
quotationCustomers.length
? db
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name })
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
inArray(
crmCustomers.id,
quotationCustomers.map((item) => item.customerId)
),
isNull(crmCustomers.deletedAt)
)
)
: Promise.resolve([]),
db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, quotation.createdBy))
.then((rows) => rows[0] ?? null)
]);
if (!customer) {
throw new AuthError('Customer not found', 404);
}
const approvalRequest = approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null;
const approvalDetail = approvalRequest
? await getApprovalRequest(approvalRequest.id, organizationId)
: null;
const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null;
const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item]));
const statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status);
const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
.map((item) => {
const step = approvalDetail.steps.find((candidate) => candidate.stepNumber === item.stepNumber);
return {
stepNumber: item.stepNumber,
roleCode: step?.roleCode ?? '',
roleName: step?.roleName ?? `Step ${item.stepNumber}`,
action: item.action,
actorName: item.actorName,
actedAt: item.actedAt,
remark: item.remark
};
}) ?? [];
return {
company: {
id: company.id,
name: company.name,
slug: company.slug,
imageUrl: company.imageUrl,
plan: company.plan
},
branch: branch
? {
id: branch.id,
code: branch.code,
label: branch.label,
value: branch.value
}
: null,
customer: {
id: customer.id,
code: customer.code,
name: customer.name,
address: customer.address,
phone: customer.phone,
email: customer.email,
taxId: customer.taxId
},
contact: contact
? {
id: contact.id,
name: contact.name,
email: contact.email,
mobile: contact.mobile,
position: contact.position
}
: null,
quotation: {
id: quotationDetail.id,
code: quotationDetail.code,
quotationDate: quotationDetail.quotationDate,
validUntil: quotationDetail.validUntil,
projectName: quotationDetail.projectName,
projectLocation: quotationDetail.projectLocation,
attention: quotationDetail.attention,
reference: quotationDetail.reference,
revision: quotationDetail.revision,
revisionLabel: toRevisionLabel(quotationDetail.revision),
statusCode,
statusLabel,
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
exchangeRate: quotationDetail.exchangeRate,
subtotal: quotationDetail.subtotal,
discount: quotationDetail.discount,
discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType),
discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType),
taxRate: quotationDetail.taxRate,
taxAmount: quotationDetail.taxAmount,
totalAmount: quotationDetail.totalAmount,
notes: quotationDetail.notes
},
items: items.map((item) => ({
id: item.id,
itemNumber: item.itemNumber,
productTypeCode: findOptionCode(productTypeOptions, item.productType),
productTypeLabel: findOptionLabel(productTypeOptions, item.productType),
description: item.description,
quantity: item.quantity,
unitCode: findOptionCode(unitOptions, item.unit),
unitLabel: findOptionLabel(unitOptions, item.unit),
unitPrice: item.unitPrice,
discount: item.discount,
discountTypeCode: findOptionCode(discountTypeOptions, item.discountType),
taxRate: item.taxRate,
totalPrice: item.totalPrice,
notes: item.notes
})),
quotationCustomers: quotationCustomers.map((item) => ({
id: item.id,
customerId: item.customerId,
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
roleCode: item.role,
roleLabel: findOptionLabel(customerRoleOptions, item.role),
isPrimary: item.isPrimary
})),
topics: {
scope: topics
.filter((item) => item.topicType === 'scope')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
exclusion: topics
.filter((item) => item.topicType === 'exclusion')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
payment: topics
.filter((item) => item.topicType === 'payment')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
other: topics
.filter((item) => !['scope', 'exclusion', 'payment'].includes(item.topicType))
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
},
approval: {
requestId: approvalDetail?.request.id ?? null,
workflowName: approvalDetail?.workflow.name ?? null,
status: approvalDetail?.request.status ?? null,
approvedAt:
approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null,
currentStep: approvalDetail?.request.currentStep ?? null,
currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null,
approvers: approvalApprovers
},
signatures: {
preparedBy: preparer?.name ?? null,
requestedBy: contact?.name ?? customer.name,
salesManager:
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
departmentManager:
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null
},
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
};
}
export async function getQuotationDocumentPreviewData(
quotationId: string,
organizationId: string
) {
const documentData = await buildQuotationDocumentData(quotationId, organizationId);
const template = await resolveTemplateForDocument(organizationId, {
documentType: 'quotation',
productType: 'default',
fileType: 'pdfme'
});
const mappings = await resolveTemplateMappings(template.version.id, organizationId);
const templateInput = mapDocumentDataToTemplateInput(
documentData as unknown as Record<string, unknown>,
mappings
);
return {
documentData,
template,
mappings,
templateInput
};
}
export async function prepareApprovedQuotationSnapshot(
quotationId: string,
organizationId: string
): Promise<ApprovedQuotationSnapshot> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
return {
quotationId,
approvedAt: preview.documentData.approval.approvedAt,
documentData: preview.documentData,
templateVersionId: preview.template.version.id,
templateInput: preview.templateInput
};
}

View File

@@ -0,0 +1,13 @@
import { apiClient } from '@/lib/api-client';
import type {
QuotationDocumentDataResponse,
QuotationDocumentPreviewResponse
} from './types';
export async function getQuotationDocumentData(id: string) {
return apiClient<QuotationDocumentDataResponse>(`/crm/quotations/${id}/document-data`);
}
export async function getQuotationDocumentPreview(id: string) {
return apiClient<QuotationDocumentPreviewResponse>(`/crm/quotations/${id}/document-preview`);
}

View File

@@ -0,0 +1,153 @@
import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate
} from '@/features/foundation/document-template/types';
export interface QuotationDocumentTopicGroup {
title: string;
items: string[];
}
export interface QuotationDocumentApprovalStep {
stepNumber: number;
roleCode: string;
roleName: string;
action: string;
actorName: string | null;
actedAt: string | null;
remark: string | null;
}
export interface QuotationDocumentData {
company: {
id: string;
name: string;
slug: string;
imageUrl: string | null;
plan: string;
};
branch: {
id: string;
code: string;
label: string;
value: string | null;
} | null;
customer: {
id: string;
code: string;
name: string;
address: string | null;
phone: string | null;
email: string | null;
taxId: string | null;
};
contact: {
id: string;
name: string;
email: string | null;
mobile: string | null;
position: string | null;
} | null;
quotation: {
id: string;
code: string;
quotationDate: string;
validUntil: string | null;
projectName: string | null;
projectLocation: string | null;
attention: string | null;
reference: string | null;
revision: number;
revisionLabel: string;
statusCode: string | null;
statusLabel: string | null;
quotationTypeCode: string | null;
quotationTypeLabel: string | null;
currencyCode: string | null;
currencyLabel: string | null;
exchangeRate: number;
subtotal: number;
discount: number;
discountTypeCode: string | null;
discountTypeLabel: string | null;
taxRate: number;
taxAmount: number;
totalAmount: number;
notes: string | null;
};
items: Array<{
id: string;
itemNumber: number;
productTypeCode: string | null;
productTypeLabel: string | null;
description: string;
quantity: number;
unitCode: string | null;
unitLabel: string | null;
unitPrice: number;
discount: number;
discountTypeCode: string | null;
taxRate: number;
totalPrice: number;
notes: string | null;
}>;
quotationCustomers: Array<{
id: string;
customerId: string;
customerName: string;
customerCode: string;
roleCode: string;
roleLabel: string | null;
isPrimary: boolean;
}>;
topics: {
scope: QuotationDocumentTopicGroup[];
exclusion: QuotationDocumentTopicGroup[];
payment: QuotationDocumentTopicGroup[];
other: QuotationDocumentTopicGroup[];
};
approval: {
requestId: string | null;
workflowName: string | null;
status: string | null;
approvedAt: string | null;
currentStep: number | null;
currentStepRoleName: string | null;
approvers: QuotationDocumentApprovalStep[];
};
signatures: {
preparedBy: string | null;
requestedBy: string | null;
salesManager: string | null;
departmentManager: string | null;
topManager: string | null;
};
watermarkStatus: string | null;
}
export interface QuotationDocumentDataResponse {
success: boolean;
time: string;
message: string;
documentData: QuotationDocumentData;
}
export interface QuotationDocumentPreviewResponse {
success: boolean;
time: string;
message: string;
preview: {
documentData: QuotationDocumentData;
template: ResolvedDocumentTemplate;
mappings: DocumentTemplateMappingWithColumns[];
templateInput: Record<string, unknown>;
};
}
export interface ApprovedQuotationSnapshot {
quotationId: string;
approvedAt: string | null;
documentData: QuotationDocumentData;
templateVersionId: string;
templateInput: Record<string, unknown>;
}

View File

@@ -1,4 +1,5 @@
import { mutationOptions } from '@tanstack/react-query'; import { mutationOptions } from '@tanstack/react-query';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import { getQueryClient } from '@/lib/query-client'; import { getQueryClient } from '@/lib/query-client';
import { import {
approveApproval, approveApproval,
@@ -16,6 +17,8 @@ function invalidateQuotationQueries(quotationId: string) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: quotationKeys.all }); queryClient.invalidateQueries({ queryKey: quotationKeys.all });
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }); queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
} }
function invalidateRelatedEntityQueries(approvalId: string) { function invalidateRelatedEntityQueries(approvalId: string) {

View File

@@ -0,0 +1,62 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { documentTemplatesQueryOptions } from '../queries';
export function TemplateSettings() {
const { data } = useSuspenseQuery(
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
);
return (
<div className='space-y-6'>
{!data.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No production document templates found.
</div>
) : (
data.items.map((template) => (
<Card key={template.id}>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-1'>
<CardTitle>{template.templateName}</CardTitle>
<CardDescription>{template.description || 'No description provided.'}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{template.isDefault ? <Badge>Default</Badge> : null}
<Badge variant={template.isActive ? 'secondary' : 'outline'}>
{template.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant='outline'>{template.fileType}</Badge>
</div>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
<div>
<div className='text-muted-foreground text-xs'>Document Type</div>
<div className='text-sm font-medium'>{template.documentType}</div>
</div>
<div>
<div className='text-muted-foreground text-xs'>Product Type</div>
<div className='text-sm font-medium'>{template.productType}</div>
</div>
<div>
<div className='text-muted-foreground text-xs'>Latest Version</div>
<div className='text-sm font-medium'>{template.latestVersion ?? '-'}</div>
</div>
<div>
<div className='text-muted-foreground text-xs'>Version Count</div>
<div className='text-sm font-medium'>{template.versionCount}</div>
</div>
<div>
<div className='text-muted-foreground text-xs'>Mapping Count</div>
<div className='text-sm font-medium'>{template.mappingCount}</div>
</div>
</CardContent>
</Card>
))
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createDocumentTemplate,
createDocumentTemplateVersion,
deleteDocumentTemplate,
updateDocumentTemplate
} from './service';
import { documentTemplateKeys } from './queries';
import type { DocumentTemplateMutationPayload, DocumentTemplateVersionMutationPayload } from './types';
export const createDocumentTemplateMutation = mutationOptions({
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
}
});
export const updateDocumentTemplateMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: Partial<DocumentTemplateMutationPayload> }) =>
updateDocumentTemplate(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
}
});
export const deleteDocumentTemplateMutation = mutationOptions({
mutationFn: (id: string) => deleteDocumentTemplate(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
}
});
export const createDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: DocumentTemplateVersionMutationPayload }) =>
createDocumentTemplateVersion(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(variables.id) });
}
});

View File

@@ -0,0 +1,29 @@
import { queryOptions } from '@tanstack/react-query';
import { getDocumentTemplateById, getDocumentTemplates, getDocumentTemplateVersions } from './service';
import type { DocumentTemplateFilters } from './types';
export const documentTemplateKeys = {
all: ['crm-document-templates'] as const,
list: (filters: DocumentTemplateFilters) =>
[...documentTemplateKeys.all, 'list', filters] as const,
detail: (id: string) => [...documentTemplateKeys.all, 'detail', id] as const,
versions: (id: string) => [...documentTemplateKeys.all, 'versions', id] as const
};
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>
queryOptions({
queryKey: documentTemplateKeys.list(filters),
queryFn: () => getDocumentTemplates(filters)
});
export const documentTemplateByIdOptions = (id: string) =>
queryOptions({
queryKey: documentTemplateKeys.detail(id),
queryFn: () => getDocumentTemplateById(id)
});
export const documentTemplateVersionsQueryOptions = (id: string) =>
queryOptions({
queryKey: documentTemplateKeys.versions(id),
queryFn: () => getDocumentTemplateVersions(id)
});

View File

@@ -0,0 +1,563 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import {
crmDocumentTemplateMappings,
crmDocumentTemplateTableColumns,
crmDocumentTemplateVersions,
crmDocumentTemplates
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
DocumentTemplateListItem,
DocumentTemplateMappingRecord,
DocumentTemplateMappingWithColumns,
DocumentTemplateMutationPayload,
DocumentTemplateRecord,
DocumentTemplateTableColumnRecord,
DocumentTemplateVersionDetail,
DocumentTemplateVersionMutationPayload,
DocumentTemplateVersionRecord,
ResolveTemplateParams,
ResolvedDocumentTemplate
} from '../types';
function mapTemplateRecord(row: typeof crmDocumentTemplates.$inferSelect): DocumentTemplateRecord {
return {
id: row.id,
organizationId: row.organizationId,
documentType: row.documentType,
productType: row.productType,
fileType: row.fileType,
templateName: row.templateName,
description: row.description,
isDefault: row.isDefault,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy,
updatedBy: row.updatedBy
};
}
function mapVersionRecord(
row: typeof crmDocumentTemplateVersions.$inferSelect
): DocumentTemplateVersionRecord {
return {
id: row.id,
organizationId: row.organizationId,
templateId: row.templateId,
version: row.version,
filePath: row.filePath,
schemaJson: row.schemaJson,
previewImageUrl: row.previewImageUrl,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
createdBy: row.createdBy
};
}
function mapMappingRecord(
row: typeof crmDocumentTemplateMappings.$inferSelect
): DocumentTemplateMappingRecord {
return {
id: row.id,
organizationId: row.organizationId,
templateVersionId: row.templateVersionId,
placeholderKey: row.placeholderKey,
sourcePath: row.sourcePath,
dataType: row.dataType as DocumentTemplateMappingRecord['dataType'],
sheetName: row.sheetName,
defaultValue: row.defaultValue,
formatMask: row.formatMask,
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function mapTableColumnRecord(
row: typeof crmDocumentTemplateTableColumns.$inferSelect
): DocumentTemplateTableColumnRecord {
return {
id: row.id,
organizationId: row.organizationId,
mappingId: row.mappingId,
columnName: row.columnName,
sourceField: row.sourceField,
columnLetter: row.columnLetter,
sortOrder: row.sortOrder,
formatMask: row.formatMask,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function assertTemplate(id: string, organizationId: string) {
const [template] = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.id, id),
eq(crmDocumentTemplates.organizationId, organizationId),
isNull(crmDocumentTemplates.deletedAt)
)
)
.limit(1);
if (!template) {
throw new AuthError('Document template not found', 404);
}
return template;
}
async function listVersionsByTemplateIds(templateIds: string[], organizationId: string) {
if (!templateIds.length) {
return [];
}
return db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
}
export async function resolveTemplateMappings(
templateVersionId: string,
organizationId: string
): Promise<DocumentTemplateMappingWithColumns[]> {
const mappings = await db
.select()
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.templateVersionId, templateVersionId),
eq(crmDocumentTemplateMappings.organizationId, organizationId),
isNull(crmDocumentTemplateMappings.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateMappings.sortOrder), asc(crmDocumentTemplateMappings.createdAt));
const columns = await db
.select()
.from(crmDocumentTemplateTableColumns)
.where(
and(
eq(crmDocumentTemplateTableColumns.organizationId, organizationId),
isNull(crmDocumentTemplateTableColumns.deletedAt)
)
)
.orderBy(
asc(crmDocumentTemplateTableColumns.sortOrder),
asc(crmDocumentTemplateTableColumns.createdAt)
);
const columnMap = new Map<string, DocumentTemplateTableColumnRecord[]>();
for (const column of columns.map(mapTableColumnRecord)) {
const items = columnMap.get(column.mappingId) ?? [];
items.push(column);
columnMap.set(column.mappingId, items);
}
return mappings.map((mapping) => ({
...mapMappingRecord(mapping),
columns: columnMap.get(mapping.id) ?? []
}));
}
export async function listDocumentTemplates(
organizationId: string,
filters: DocumentTemplateFilters = {}
): Promise<DocumentTemplateListItem[]> {
const templates = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
isNull(crmDocumentTemplates.deletedAt),
...(filters.documentType ? [eq(crmDocumentTemplates.documentType, filters.documentType)] : []),
...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []),
...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []),
...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : [])
)
)
.orderBy(asc(crmDocumentTemplates.templateName));
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const mappings = await db
.select()
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.organizationId, organizationId),
isNull(crmDocumentTemplateMappings.deletedAt)
)
);
return templates.map((template) => {
const templateVersions = versions.filter((item) => item.templateId === template.id);
const latestVersion = templateVersions.at(-1) ?? null;
const templateVersionIds = new Set(templateVersions.map((item) => item.id));
const mappingCount = mappings.filter((item) => templateVersionIds.has(item.templateVersionId)).length;
return {
...mapTemplateRecord(template),
latestVersion: latestVersion?.version ?? null,
versionCount: templateVersions.length,
mappingCount
};
});
}
export async function getDocumentTemplate(
id: string,
organizationId: string
): Promise<DocumentTemplateDetail> {
const template = await assertTemplate(id, organizationId);
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, id),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const versionsWithMappings: DocumentTemplateVersionDetail[] = [];
let mappingCount = 0;
for (const version of versions) {
const mappings = await resolveTemplateMappings(version.id, organizationId);
mappingCount += mappings.length;
versionsWithMappings.push({
...mapVersionRecord(version),
mappings
});
}
return {
...mapTemplateRecord(template),
versions: versionsWithMappings,
mappingCount
};
}
export async function listDocumentTemplateVersions(id: string, organizationId: string) {
await assertTemplate(id, organizationId);
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.templateId, id),
eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
return Promise.all(
versions.map(async (version) => ({
...mapVersionRecord(version),
mappings: await resolveTemplateMappings(version.id, organizationId)
}))
);
}
export async function createDocumentTemplate(
organizationId: string,
userId: string,
payload: DocumentTemplateMutationPayload
) {
const [created] = await db
.insert(crmDocumentTemplates)
.values({
id: crypto.randomUUID(),
organizationId,
documentType: payload.documentType,
productType: payload.productType,
fileType: payload.fileType,
templateName: payload.templateName.trim(),
description: payload.description?.trim() || null,
isDefault: payload.isDefault ?? false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
if (created.isDefault) {
await normalizeDefaultTemplate(created.id, organizationId, created.documentType, created.fileType);
}
return created;
}
export async function updateDocumentTemplate(
id: string,
organizationId: string,
userId: string,
payload: Partial<DocumentTemplateMutationPayload>
) {
const template = await assertTemplate(id, organizationId);
const [updated] = await db
.update(crmDocumentTemplates)
.set({
documentType: payload.documentType ?? template.documentType,
productType: payload.productType ?? template.productType,
fileType: payload.fileType ?? template.fileType,
templateName: payload.templateName?.trim() ?? template.templateName,
description: payload.description === undefined ? template.description : payload.description?.trim() || null,
isDefault: payload.isDefault ?? template.isDefault,
isActive: payload.isActive ?? template.isActive,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmDocumentTemplates.id, id))
.returning();
if (updated.isDefault) {
await normalizeDefaultTemplate(updated.id, organizationId, updated.documentType, updated.fileType);
}
return updated;
}
async function normalizeDefaultTemplate(
templateId: string,
organizationId: string,
documentType: string,
fileType: string
) {
await db
.update(crmDocumentTemplates)
.set({
isDefault: false,
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
eq(crmDocumentTemplates.documentType, documentType),
eq(crmDocumentTemplates.fileType, fileType),
isNull(crmDocumentTemplates.deletedAt)
)
);
await db
.update(crmDocumentTemplates)
.set({
isDefault: true,
updatedAt: new Date()
})
.where(eq(crmDocumentTemplates.id, templateId));
}
export async function softDeleteDocumentTemplate(id: string, organizationId: string, userId: string) {
await assertTemplate(id, organizationId);
const [updated] = await db
.update(crmDocumentTemplates)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmDocumentTemplates.id, id))
.returning();
return updated;
}
export async function createDocumentTemplateVersion(
templateId: string,
organizationId: string,
userId: string,
payload: DocumentTemplateVersionMutationPayload
) {
await assertTemplate(templateId, organizationId);
const [created] = await db
.insert(crmDocumentTemplateVersions)
.values({
id: crypto.randomUUID(),
organizationId,
templateId,
version: payload.version.trim(),
filePath: payload.filePath?.trim() || null,
schemaJson: payload.schemaJson,
previewImageUrl: payload.previewImageUrl?.trim() || null,
isActive: payload.isActive ?? true,
createdBy: userId
})
.returning();
return created;
}
export async function resolveTemplateForDocument(
organizationId: string,
params: ResolveTemplateParams
): Promise<ResolvedDocumentTemplate> {
const templates = await db
.select()
.from(crmDocumentTemplates)
.where(
and(
eq(crmDocumentTemplates.organizationId, organizationId),
eq(crmDocumentTemplates.documentType, params.documentType),
eq(crmDocumentTemplates.fileType, params.fileType),
isNull(crmDocumentTemplates.deletedAt),
eq(crmDocumentTemplates.isActive, true)
)
)
.orderBy(asc(crmDocumentTemplates.createdAt));
const exactTemplate =
templates.find(
(template) => template.productType === (params.productType ?? 'default') && template.isDefault
) ??
templates.find((template) => template.productType === (params.productType ?? 'default')) ??
templates.find((template) => template.productType === 'default' && template.isDefault) ??
templates.find((template) => template.productType === 'default') ??
templates.find((template) => template.isDefault) ??
templates[0];
if (!exactTemplate) {
throw new AuthError('No active document template found', 404);
}
const versions = await db
.select()
.from(crmDocumentTemplateVersions)
.where(
and(
eq(crmDocumentTemplateVersions.organizationId, organizationId),
eq(crmDocumentTemplateVersions.templateId, exactTemplate.id),
eq(crmDocumentTemplateVersions.isActive, true),
isNull(crmDocumentTemplateVersions.deletedAt)
)
)
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
const version = versions.at(-1);
if (!version) {
throw new AuthError('No active template version found', 404);
}
return {
template: mapTemplateRecord(exactTemplate),
version: mapVersionRecord(version)
};
}
function getValueByPath(source: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((current, segment) => {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current) && /^\d+$/.test(segment)) {
return current[Number(segment)];
}
if (typeof current === 'object' && current !== null && segment in current) {
return (current as Record<string, unknown>)[segment];
}
return undefined;
}, source);
}
function applyFormatMask(value: unknown, formatMask?: string | null) {
if (value === null || value === undefined) {
return value;
}
if (formatMask === 'currency' && typeof value === 'number') {
return value.toLocaleString();
}
if (formatMask === 'date' && typeof value === 'string') {
return new Date(value).toLocaleDateString();
}
return value;
}
export function mapDocumentDataToTemplateInput(
documentData: Record<string, unknown>,
mappings: DocumentTemplateMappingWithColumns[]
) {
const result: Record<string, unknown> = {};
for (const mapping of mappings) {
const rawValue = getValueByPath(documentData, mapping.sourcePath);
if (mapping.dataType === 'table') {
const rows = Array.isArray(rawValue) ? rawValue : [];
result[mapping.placeholderKey] = rows.map((row) => {
const rowRecord = row as Record<string, unknown>;
return mapping.columns.reduce<Record<string, unknown>>((accumulator, column) => {
accumulator[column.columnName] = applyFormatMask(
rowRecord[column.sourceField],
column.formatMask
);
return accumulator;
}, {});
});
continue;
}
if (mapping.dataType === 'multiline') {
if (Array.isArray(rawValue)) {
const normalized = rawValue
.map((item) => {
if (typeof item === 'string') {
return item;
}
if (item && typeof item === 'object' && 'items' in item) {
return ((item as { items?: string[] }).items ?? []).join('\n');
}
return String(item ?? '');
})
.filter(Boolean)
.join('\n\n');
result[mapping.placeholderKey] = normalized || mapping.defaultValue || '';
continue;
}
result[mapping.placeholderKey] =
typeof rawValue === 'string' ? rawValue : mapping.defaultValue || '';
continue;
}
result[mapping.placeholderKey] =
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '';
}
return result;
}

View File

@@ -0,0 +1,64 @@
import { apiClient } from '@/lib/api-client';
import type {
DocumentTemplateDetailResponse,
DocumentTemplateFilters,
DocumentTemplateListResponse,
DocumentTemplateMutationPayload,
DocumentTemplateVersionMutationPayload,
DocumentTemplateVersionsResponse,
MutationSuccessResponse
} from './types';
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
const searchParams = new URLSearchParams();
if (filters.documentType) searchParams.set('documentType', filters.documentType);
if (filters.fileType) searchParams.set('fileType', filters.fileType);
if (filters.isActive) searchParams.set('isActive', filters.isActive);
const query = searchParams.toString();
return apiClient<DocumentTemplateListResponse>(
`/crm/document-templates${query ? `?${query}` : ''}`
);
}
export async function getDocumentTemplateById(id: string) {
return apiClient<DocumentTemplateDetailResponse>(`/crm/document-templates/${id}`);
}
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/document-templates', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateDocumentTemplate(
id: string,
data: Partial<DocumentTemplateMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteDocumentTemplate(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
method: 'DELETE'
});
}
export async function getDocumentTemplateVersions(id: string) {
return apiClient<DocumentTemplateVersionsResponse>(`/crm/document-templates/${id}/versions`);
}
export async function createDocumentTemplateVersion(
id: string,
data: DocumentTemplateVersionMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}/versions`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -0,0 +1,141 @@
export interface DocumentTemplateRecord {
id: string;
organizationId: string;
documentType: string;
productType: string;
fileType: string;
templateName: string;
description: string | null;
isDefault: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface DocumentTemplateVersionRecord {
id: string;
organizationId: string;
templateId: string;
version: string;
filePath: string | null;
schemaJson: unknown;
previewImageUrl: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
}
export interface DocumentTemplateMappingRecord {
id: string;
organizationId: string;
templateVersionId: string;
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
sheetName: string | null;
defaultValue: string | null;
formatMask: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentTemplateTableColumnRecord {
id: string;
organizationId: string;
mappingId: string;
columnName: string;
sourceField: string;
columnLetter: string | null;
sortOrder: number;
formatMask: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
columns: DocumentTemplateTableColumnRecord[];
}
export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord {
mappings: DocumentTemplateMappingWithColumns[];
}
export interface DocumentTemplateDetail extends DocumentTemplateRecord {
versions: DocumentTemplateVersionDetail[];
mappingCount: number;
}
export interface DocumentTemplateListItem extends DocumentTemplateRecord {
latestVersion: string | null;
versionCount: number;
mappingCount: number;
}
export interface DocumentTemplateFilters {
documentType?: string;
fileType?: string;
isActive?: string;
}
export interface DocumentTemplateListResponse {
success: boolean;
time: string;
message: string;
items: DocumentTemplateListItem[];
}
export interface DocumentTemplateDetailResponse {
success: boolean;
time: string;
message: string;
template: DocumentTemplateDetail;
}
export interface DocumentTemplateVersionsResponse {
success: boolean;
time: string;
message: string;
items: DocumentTemplateVersionDetail[];
}
export interface DocumentTemplateMutationPayload {
documentType: string;
productType: string;
fileType: string;
templateName: string;
description?: string | null;
isDefault?: boolean;
isActive?: boolean;
}
export interface DocumentTemplateVersionMutationPayload {
version: string;
filePath?: string | null;
schemaJson: unknown;
previewImageUrl?: string | null;
isActive?: boolean;
}
export interface ResolveTemplateParams {
documentType: string;
productType?: string | null;
fileType: string;
}
export interface ResolvedDocumentTemplate {
template: DocumentTemplateRecord;
version: DocumentTemplateVersionRecord;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -52,7 +52,12 @@ export const PERMISSIONS = {
crmApprovalSubmit: 'crm.approval.submit', crmApprovalSubmit: 'crm.approval.submit',
crmApprovalApprove: 'crm.approval.approve', crmApprovalApprove: 'crm.approval.approve',
crmApprovalReject: 'crm.approval.reject', crmApprovalReject: 'crm.approval.reject',
crmApprovalReturn: 'crm.approval.return' crmApprovalReturn: 'crm.approval.return',
crmDocumentTemplateRead: 'crm.document_template.read',
crmDocumentTemplateCreate: 'crm.document_template.create',
crmDocumentTemplateUpdate: 'crm.document_template.update',
crmDocumentTemplateDelete: 'crm.document_template.delete',
crmQuotationDocumentPreview: 'crm.quotation.document.preview'
} as const; } as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -94,7 +99,12 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalApprove, PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject, PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate,
PERMISSIONS.crmDocumentTemplateDelete,
PERMISSIONS.crmQuotationDocumentPreview
]; ];
} }
@@ -105,7 +115,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmQuotationDocumentPreview
]; ];
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long