From 14acee1127bd944b4927e957356818a56b9e8abf Mon Sep 17 00:00:00 2001 From: phaichayon Date: Thu, 25 Jun 2026 21:09:27 +0700 Subject: [PATCH] task-fixdatetime --- package.json | 1 + plans/task-datetime.md | 259 ++ plans/task-gendata.md | 614 ++++ src/app/terms-of-service/page.tsx | 8 +- src/components/github-stars-button.tsx | 5 +- src/components/ui/chart.tsx | 3 +- src/components/ui/notification-card.tsx | 18 +- .../ui/table/data-table-slider-filter.tsx | 12 +- src/db/seeds/crm-uat.seed.ts | 2924 +++++++++++++++++ src/features/chat/components/messenger.tsx | 6 +- src/features/chat/utils/store.ts | 6 +- src/features/crm-demo/utils/format.ts | 8 +- .../customers/components/customer-columns.tsx | 13 +- .../customers/components/customer-detail.tsx | 12 +- .../components/dashboard-approval-metrics.tsx | 12 +- .../components/dashboard-followups.tsx | 12 +- .../crm/leads/components/lead-columns.tsx | 17 +- .../crm/leads/components/lead-detail.tsx | 25 +- .../leads/components/lead-followup-panel.tsx | 7 +- .../components/opportunity-columns.tsx | 36 +- .../components/opportunity-detail.tsx | 63 +- .../components/opportunity-followups-tab.tsx | 12 +- .../components/opportunity-outcome-card.tsx | 91 +- .../components/quotation-columns.tsx | 20 +- .../components/quotation-detail.tsx | 1195 +++---- .../components/quotation-document-preview.tsx | 18 +- .../document/server/pdfme-transforms.ts | 16 +- .../reports/components/aging-report-view.tsx | 34 +- src/features/crm/shared/formats.ts | 24 +- .../components/example-product-table.tsx | 3 +- .../approval/components/approval-columns.tsx | 8 +- .../components/approval-request-panel.tsx | 56 +- .../components/document-sequence-settings.tsx | 79 +- src/lib/date-format.ts | 95 + src/lib/format.ts | 68 +- src/lib/number-format.ts | 19 + 36 files changed, 4788 insertions(+), 1011 deletions(-) create mode 100644 plans/task-datetime.md create mode 100644 plans/task-gendata.md create mode 100644 src/db/seeds/crm-uat.seed.ts create mode 100644 src/lib/date-format.ts create mode 100644 src/lib/number-format.ts diff --git a/package.json b/package.json index 0e36b36..65897c0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "studio": "npx drizzle-kit studio", "seed:super-admin": "node scripts/seed-super-admin.js", "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", + "seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts", "seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", "migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", diff --git a/plans/task-datetime.md b/plans/task-datetime.md new file mode 100644 index 0000000..f7f73bf --- /dev/null +++ b/plans/task-datetime.md @@ -0,0 +1,259 @@ +# Task: Global Date & Time Formatting Standard (Hydration-safe) + +## Objective + +Eliminate all React hydration mismatch issues caused by inconsistent date formatting between Server Components and Client Components. + +Establish a single global date formatting standard across the entire application. + +--- + +## Problem + +Current implementation uses: + +* Date.toLocaleString() +* Date.toLocaleDateString() +* Date.toLocaleTimeString() + +These APIs depend on: + +* Browser Locale +* Operating System Locale +* Server Locale +* Server Timezone +* Browser Timezone + +This causes inconsistent rendering between SSR and Client Hydration. + +Example + +Server + +16/6/2569 17:00:00 + +Client + +6/16/2026, 5:00:00 PM + +Result + +React Hydration Error + +--- + +## Requirements + +### 1. Create Global Date Utility + +Create + +``` +src/lib/date-format.ts +``` + +All UI must use this utility. + +--- + +### 2. Never use + +Forbidden + +``` +toLocaleString() + +toLocaleDateString() + +toLocaleTimeString() + +Intl.DateTimeFormat() + +new Date().toString() +``` + +directly inside React components. + +--- + +### 3. Standard Display Format + +Date only + +``` +YYYY-MM-DD + +Example + +2026-06-25 +``` + +Date Time + +``` +YYYY-MM-DD HH:mm:ss + +Example + +2026-06-25 14:35:20 +``` + +Time + +``` +HH:mm:ss +``` + +Month + +``` +YYYY-MM +``` + +Year + +``` +YYYY +``` + +--- + +### 4. Timezone + +Always + +``` +Asia/Bangkok +``` + +Never depend on browser timezone. + +--- + +### 5. Calendar + +Always use + +Gregorian Calendar + +Never Buddhist Calendar. + +Display + +2026 + +Never + +2569 + +--- + +### 6. SSR Safe + +Formatting must produce identical output on + +* Server +* Client +* Static Render +* Hydration + +No locale-dependent output. + +--- + +### 7. Usage + +Replace every occurrence of + +``` +toLocaleString() + +toLocaleDateString() + +toLocaleTimeString() + +Intl.DateTimeFormat() +``` + +with + +``` +formatDate() + +formatDateTime() + +formatTime() + +formatMonth() + +formatYear() +``` + +--- + +### 8. Dashboard + +Update all CRM Dashboard components. + +Especially + +* Approval Metrics +* Lead Tables +* Opportunity Tables +* Quotation Tables +* Activity Timeline +* Follow-up +* Reports + +--- + +### 9. Tables + +Default date column + +``` +YYYY-MM-DD +``` + +Only show time when business requires it. + +--- + +### 10. Forms + +Date Picker + +Store + +ISO8601 UTC + +Display + +YYYY-MM-DD + +--- + +### 11. API + +API returns + +ISO8601 + +Example + +``` +2026-06-25T10:35:20.000Z +``` + +UI handles formatting. + +--- + +## Acceptance Criteria + +* No Hydration mismatch. +* No locale-dependent rendering. +* Same output in Server and Client. +* All dashboard pages use global formatter. +* All table date columns display YYYY-MM-DD. +* No usage of toLocaleString(), toLocaleDateString(), toLocaleTimeString() inside UI components. diff --git a/plans/task-gendata.md b/plans/task-gendata.md new file mode 100644 index 0000000..2fe4a21 --- /dev/null +++ b/plans/task-gendata.md @@ -0,0 +1,614 @@ +# Prompt : Generate UAT Seed Data for ALLA CRM + +Role: +You are a Senior Solution Architect, Business Analyst, and Data Architect responsible for preparing realistic UAT seed data for the ALLA CRM system. + +## Objective + +Generate business-oriented seed data that demonstrates the complete CRM workflow. + +The goal is NOT to create random records. + +Every record must belong to a realistic business scenario so that end users can understand the system immediately during UAT. + +--- + +## General Rules + +* All data must be internally consistent. +* Every document must reference existing related data. +* Dates should simulate approximately the last 90 days. +* Mix Draft, In Progress, Completed, Won, Lost and Cancelled cases. +* Generate believable Thai company names, projects and contacts. +* Avoid Lorem Ipsum. +* Avoid fake meaningless values. + +--- + +# Business Scenarios + +Create at least 15 complete business stories. + +Example: + +## Story 1 + +Lead received from Website + +Customer: +Bangkok Food Industry Co.,Ltd. + +Project: +Warehouse Crane Installation + +Flow + +Lead +↓ + +Marketing Follow-up + +↓ + +Assign to Sales + +↓ + +Opportunity + +↓ + +Site Survey + +↓ + +Requirement Gathering + +↓ + +Quotation + +↓ + +Approval + +↓ + +Sent to Customer + +↓ + +PO Received + +Story should contain every related record. + +--- + +Story 2 + +Lead Lost + +Reason + +Budget too high + +Competitor: +Konecranes + +Outcome + +Closed Lost + +--- + +Story 3 + +Customer requested Revision + +Quotation R01 + +↓ + +Rejected by customer + +↓ + +Quotation R02 + +↓ + +Approved + +↓ + +PO + +--- + +Story 4 + +No Quotation + +Reason + +Customer cancelled project + +--- + +Story 5 + +Large project + +Multiple project parties + +Billing Customer + +Contractor + +Consultant + +End Customer + +--- + +Story 6 + +Quotation waiting approval + +Sales Manager pending + +--- + +Story 7 + +Approval rejected + +Return to Sales + +Create Revision + +--- + +Story 8 + +Follow-up overdue + +Sales forgot to contact customer + +--- + +Story 9 + +High Value Project + +Estimated value + +35 Million THB + +Chance + +80% + +Priority + +High + +--- + +Story 10 + +Service Project + +Product Type + +Service + +Maintenance Contract + +--- + +Continue until at least 15 stories. + +--- + +## Required Master Data + +Generate realistic master data. + +Organizations + +Branches + +Product Types + +Sales Teams + +Marketing Teams + +Users + +Approval Roles + +Customers + +Customer Groups + +Contacts + +Sites + +Industries + +Competitors + +Lead Sources + +Currencies + +Units + +Payment Terms + +Delivery Terms + +Master Options + +--- + +## Customer Requirements + +Create approximately + +50 Customers + +Each customer has + +1–5 Sites + +Each site has + +2–6 Contacts + +Different industries + +Construction + +Factory + +Food + +Automotive + +Power Plant + +Logistics + +Hospital + +Government + +University + +Commercial Building + +--- + +## Lead Requirements + +Generate approximately + +80 Leads + +Status distribution + +20 New + +15 Contacted + +10 Assigned + +15 Opportunity + +10 Closed Won + +8 Closed Lost + +2 Cancelled + +Different Lead Sources + +Website + +Facebook + +LINE OA + +Referral + +Existing Customer + +Sales Visit + +Exhibition + +Phone Call + +--- + +## Opportunity Requirements + +Generate approximately + +40 Opportunities + +Different pipeline stages + +Requirement Gathering + +Budget Checking + +Competitor Analysis + +Proposal Preparation + +Quotation + +Negotiation + +PO Pending + +--- + +## Quotation Requirements + +Generate approximately + +60 Quotations + +Mix + +Draft + +Pending Approval + +Approved + +Sent + +Following Up + +Won + +Lost + +Cancelled + +Around 20 quotations should have Revision R02 or R03. + +--- + +Each quotation should contain + +Header + +Customer + +Project + +Project Parties + +Items + +Topics + +Scope + +Exclusion + +Payment Terms + +Delivery + +Warranty + +Attachments + +Follow-ups + +Approval History + +Remarks + +Currency + +Tax + +Discount + +Totals + +--- + +## Approval Data + +Generate realistic approval history. + +Sales Manager + +Department Manager + +Managing Director + +Include + +Approved + +Rejected + +Pending + +Returned + +Approval timestamps must be realistic. + +--- + +## PO Data + +Generate + +25 Purchase Orders + +Linked to approved quotations only. + +Include + +PO Number + +PO Date + +Currency + +Amount + +--- + +## Dashboard Distribution + +Data should produce meaningful dashboards. + +Examples + +Sales Pipeline + +Quotation by Status + +Revenue Forecast + +Won vs Lost + +Top Customers + +Top Salespersons + +Follow-up Due Today + +Overdue Follow-up + +Opportunity Value + +Monthly Revenue + +Branch Comparison + +Product Type Comparison + +Lead Source Analysis + +Competitor Analysis + +Conversion Rate + +--- + +## Follow-up Activities + +Every Lead, Opportunity and Quotation should contain timeline activities. + +Examples + +Phone Call + +LINE + +Email + +Meeting + +Site Survey + +Customer Visit + +Quotation Sent + +Reminder + +PO Follow-up + +Each activity should contain + +Date + +Owner + +Remark + +Next Follow-up Date + +Status + +--- + +## Data Quality + +Every foreign key must be valid. + +Every document number must follow project numbering rules. + +No orphan records. + +No duplicated business logic. + +Statuses must match the workflow. + +Approval history must match document status. + +Won quotations must have PO. + +Lost quotations must contain Lost Reason. + +Cancelled documents must contain Cancel Reason. + +No Quotation cases must contain No Quotation Reason. + +--- + +## Output + +Generate the seed in modular files. + +seed/ + +master/ + +organization.seed.ts + +branch.seed.ts + +users.seed.ts + +customers.seed.ts + +contacts.seed.ts + +lead.seed.ts + +opportunity.seed.ts + +quotation.seed.ts + +approval.seed.ts + +purchase-order.seed.ts + +followup.seed.ts + +attachments.seed.ts + +dashboard.seed.ts + +The seed must be idempotent. + +Running multiple times should not create duplicated business records. + +The generated data should allow UAT users to experience every important workflow in the CRM system without manually creating records. diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx index f26e7f1..a844c45 100644 --- a/src/app/terms-of-service/page.tsx +++ b/src/app/terms-of-service/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import { formatDate } from '@/lib/date-format'; export const metadata: Metadata = { title: 'Terms of Service', @@ -15,12 +16,7 @@ export default function TermsOfServicePage() {

Terms of Service

- Last updated:{' '} - {new Date().toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric' - })} + Last updated: {formatDate(new Date())}

diff --git a/src/components/github-stars-button.tsx b/src/components/github-stars-button.tsx index 116757d..cd91223 100644 --- a/src/components/github-stars-button.tsx +++ b/src/components/github-stars-button.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; +import { formatNumber } from '@/lib/number-format'; import { cn } from '@/lib/utils'; // ─── GitHub API helpers (self-contained) ───────────────────────────────────── @@ -38,7 +39,7 @@ function formatCount(count: number): string { const value = count / 1_000; return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k`; } - return count.toLocaleString('en-US'); + return formatNumber(count); } // ─── Component ─────────────────────────────────────────────────────────────── @@ -131,7 +132,7 @@ async function GitHubStarsButton({ target='_blank' rel='noopener noreferrer' data-slot='github-stars-button' - aria-label={`${fullName} on GitHub${stars !== null ? ` — ${stars.toLocaleString('en-US')} stars` : ''}`} + aria-label={`${fullName} on GitHub${stars !== null ? ` — ${formatNumber(stars)} stars` : ''}`} className={cn(githubStarsButtonVariants({ variant, size, className }))} {...props} > diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 1860c70..22a9b2a 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import * as RechartsPrimitive from 'recharts'; +import { formatNumber } from '@/lib/number-format'; import { cn } from '@/lib/utils'; // Format: { THEME_NAME: CSS_SELECTOR } @@ -219,7 +220,7 @@ function ChartTooltipContent({ {item.value && ( - {item.value.toLocaleString()} + {Array.isArray(item.value) ? item.value.join(', ') : formatNumber(item.value)} )} diff --git a/src/components/ui/notification-card.tsx b/src/components/ui/notification-card.tsx index 49e6557..f09d722 100644 --- a/src/components/ui/notification-card.tsx +++ b/src/components/ui/notification-card.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { Icons } from '@/components/icons'; +import { formatDateTime } from '@/lib/date-format'; import { cn } from '@/lib/utils'; export type NotificationStatus = 'unread' | 'read' | 'archived'; @@ -30,22 +31,7 @@ export interface NotificationCardProps { } const formatDate = (date: string | Date): string => { - const d = new Date(date); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - - return d.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric' - }); + return formatDateTime(date); }; const getActionIcon = (actionType: ActionType) => { diff --git a/src/components/ui/table/data-table-slider-filter.tsx b/src/components/ui/table/data-table-slider-filter.tsx index 72a2642..e602181 100644 --- a/src/components/ui/table/data-table-slider-filter.tsx +++ b/src/components/ui/table/data-table-slider-filter.tsx @@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Separator } from '@/components/ui/separator'; import { Slider } from '@/components/ui/slider'; +import { formatNumber } from '@/lib/number-format'; import { cn } from '@/lib/utils'; import { Icons } from '@/components/icons'; @@ -76,7 +77,7 @@ export function DataTableSliderFilter({ column, title }: DataTableSliderF }, [columnFilterValue, min, max]); const formatValue = React.useCallback((value: number) => { - return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return formatNumber(value, { maximumFractionDigits: 0 }); }, []); const onFromInputChange = React.useCallback( @@ -108,12 +109,9 @@ export function DataTableSliderFilter({ column, title }: DataTableSliderF [column] ); - const onReset = React.useCallback( - () => { - column.setFilterValue(undefined); - }, - [column] - ); + const onReset = React.useCallback(() => { + column.setFilterValue(undefined); + }, [column]); return (
diff --git a/src/db/seeds/crm-uat.seed.ts b/src/db/seeds/crm-uat.seed.ts new file mode 100644 index 0000000..78f1291 --- /dev/null +++ b/src/db/seeds/crm-uat.seed.ts @@ -0,0 +1,2924 @@ +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { pathToFileURL } from "node:url"; +import postgres from "postgres"; + +type SqlClient = ReturnType; + +type OrganizationRow = { + id: string; + name: string; + createdBy: string; +}; + +type UserMembershipRow = { + userId: string; + userName: string | null; + email: string; + systemRole: string; + membershipRole: string; + businessRole: string; +}; + +type OptionRow = { + id: string; + category: string; + code: string; + label: string; + parentId: string | null; +}; + +type BranchRow = { + id: string; + code: string; + label: string; +}; + +type StoryConfig = { + slug: string; + title: string; + customerName: string; + province: string; + district: string; + branchCode: string; + customerGroupCode: string; + customerSubGroupCode: string; + productTypeCode: string; + quotationTypeCode: string; + priorityCode: string; + leadChannelCode: string; + awarenessCode: string; + leadStatusCode: string; + leadFollowupStatusCode: string; + opportunityStatusCode: string; + chancePercent: number; + estimatedValue: number; + projectName: string; + projectLocation: string; + leadDescription: string; + competitor?: string; + quotePlan: + | "accepted" + | "pending_step_1" + | "pending_step_2" + | "approved" + | "rejected_then_revised" + | "returned_then_revised" + | "sent_follow_up" + | "cancelled"; + opportunityOutcome: "won" | "lost" | "cancelled" | "no_quotation" | "open"; + lostReasonCode?: string; + cancelReasonCode?: string; + noQuotationReasonCode?: string; + multiParties?: Array<{ + roleCode: "billing_customer" | "consultant" | "contractor" | "end_customer"; + customerName: string; + }>; +}; + +type SeedCustomer = { + id: string; + code: string; + name: string; + branchId: string | null; + province: string; + district: string; + customerTypeId: string; + customerStatusId: string; + customerGroupId: string | null; + customerSubGroupId: string | null; + leadChannelId: string | null; + ownerUserId: string; + notes: string; + createdBy: string; + email: string; + phone: string; + website: string; +}; + +type SeedContact = { + id: string; + customerId: string; + name: string; + position: string; + department: string; + phone: string; + mobile: string; + email: string; + isPrimary: boolean; + notes: string; + createdBy: string; +}; + +type SeedLead = { + id: string; + code: string; + customerId: string | null; + contactId: string | null; + branchId: string | null; + description: string; + projectName: string; + projectLocation: string; + productTypeId: string | null; + priorityId: string | null; + estimatedValue: number | null; + awarenessId: string | null; + statusId: string; + followupStatusId: string | null; + lostReasonId: string | null; + outcome: "open" | "won" | "lost"; + ownerMarketingUserId: string | null; + assignedSalesOwnerId: string | null; + assignedAt: Date | null; + assignedBy: string | null; + assignmentRemark: string | null; + createdBy: string; + createdAt: Date; +}; + +type SeedLeadFollowup = { + id: string; + leadId: string; + branchId: string | null; + userId: string; + followupDate: Date; + followupStatusId: string; + note: string | null; + nextFollowupDate: Date | null; + createdAt: Date; +}; + +type SeedOpportunity = { + id: string; + code: string; + customerId: string; + contactId: string | null; + leadId: string | null; + branchId: string | null; + title: string; + description: string | null; + requirement: string | null; + projectName: string | null; + projectLocation: string | null; + productTypeId: string; + statusId: string; + outcomeStatus: "open" | "won" | "lost" | "cancelled" | "no_quotation"; + priorityId: string; + leadChannelId: string | null; + estimatedValue: number | null; + chancePercent: number | null; + expectedCloseDate: Date | null; + competitor: string | null; + source: string; + notes: string | null; + isHotProject: boolean; + isActive: boolean; + pipelineStage: "lead" | "opportunity" | "closed_won" | "closed_lost"; + closedAt: Date | null; + closedWonAt: Date | null; + closedLostAt: Date | null; + closedByUserId: string | null; + poNumber: string | null; + poDate: Date | null; + poAmount: number | null; + poCurrencyId: string | null; + lostReasonId: string | null; + lostDetail: string | null; + lostCompetitor: string | null; + lostRemark: string | null; + cancelReasonId: string | null; + noQuotationReasonId: string | null; + assignedToUserId: string | null; + assignedAt: Date | null; + assignedBy: string | null; + assignmentRemark: string | null; + createdBy: string; + updatedBy: string; + createdAt: Date; + updatedAt: Date; +}; + +type SeedOpportunityParty = { + id: string; + opportunityId: string; + customerId: string; + roleId: string; + remark: string | null; +}; + +type SeedOpportunityFollowup = { + id: string; + opportunityId: string; + contactId: string | null; + followupDate: Date; + followupTypeId: string; + outcome: string | null; + notes: string | null; + nextFollowupDate: Date | null; + nextAction: string | null; + createdBy: string; + updatedBy: string; +}; + +type SeedQuotation = { + id: string; + code: string; + opportunityId: string | null; + customerId: string; + contactId: string | null; + branchId: string | null; + quotationDate: Date; + validUntil: Date | null; + quotationTypeId: string; + projectName: string | null; + projectLocation: string | null; + attention: string | null; + reference: string | null; + notes: string | null; + statusId: string; + revision: number; + parentQuotationId: string | null; + revisionRemark: string | null; + currencyId: string; + exchangeRate: number; + subtotal: number; + discount: number; + discountTypeId: string | null; + taxRate: number; + taxAmount: number; + totalAmount: number; + chancePercent: number | null; + isHotProject: boolean; + competitor: string | null; + salesmanId: string | null; + isSent: boolean; + sentAt: Date | null; + sentViaId: string | null; + approvedAt: Date | null; + acceptedAt: Date | null; + rejectedAt: Date | null; + rejectionReason: string | null; + createdBy: string; + updatedBy: string; + createdAt: Date; + updatedAt: Date; +}; + +type SeedQuotationItem = { + id: string; + quotationId: string; + itemNumber: number; + productTypeId: string; + description: string; + quantity: number; + unitId: string | null; + unitPrice: number; + discount: number; + discountTypeId: string | null; + taxRate: number; + totalPrice: number; + notes: string | null; + sortOrder: number; +}; + +type SeedQuotationParty = { + id: string; + quotationId: string; + customerId: string; + roleId: string; + isPrimary: boolean; + remark: string | null; +}; + +type SeedQuotationTopic = { + id: string; + quotationId: string; + topicTypeId: string; + title: string; + sortOrder: number; + items: Array<{ + id: string; + content: string; + sortOrder: number; + }>; +}; + +type SeedQuotationFollowup = { + id: string; + quotationId: string; + contactId: string | null; + followupDate: Date; + followupTypeId: string; + outcome: string | null; + notes: string | null; + nextFollowupDate: Date | null; + nextAction: string | null; + createdBy: string; + updatedBy: string; +}; + +type SeedApprovalRequest = { + id: string; + workflowId: string; + entityId: string; + currentStep: number; + status: "pending" | "approved" | "rejected" | "returned"; + requestedBy: string; + requestedAt: Date; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; +}; + +type SeedApprovalAction = { + id: string; + approvalRequestId: string; + stepNumber: number; + action: "submit" | "approve" | "reject" | "return"; + remark: string | null; + actedBy: string; + actedAt: Date; +}; + +type UserContext = { + admin: UserMembershipRow; + marketing: UserMembershipRow; + sales: UserMembershipRow[]; + salesManager: UserMembershipRow; + departmentManager: UserMembershipRow; + topManager: UserMembershipRow; +}; + +const SEED_TAG = "crm-uat-seed-v1"; +const PERIOD = "2606"; + +const STORIES: StoryConfig[] = [ + { + slug: "website-warehouse-crane-win", + title: "Lead received from website and won after approval", + customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด", + province: "Bangkok", + district: "Min Buri", + branchCode: "head_office", + customerGroupCode: "industrial", + customerSubGroupCode: "factory", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "high", + leadChannelCode: "website", + awarenessCode: "google_search_website", + leadStatusCode: "assigned", + leadFollowupStatusCode: "waiting_po", + opportunityStatusCode: "negotiation", + chancePercent: 80, + estimatedValue: 12400000, + projectName: "Warehouse Crane Installation Phase 2", + projectLocation: "Bangkok Food Industrial Estate, Min Buri", + leadDescription: + "Customer asked for top-running overhead crane to support raw material warehouse expansion.", + quotePlan: "accepted", + opportunityOutcome: "won", + multiParties: [ + { + roleCode: "contractor", + customerName: "บริษัท ซีวิล โปรเจค แอนด์ คอนสตรัคชั่น จำกัด", + }, + { + roleCode: "end_customer", + customerName: "บริษัท บางกอก ฟู้ด โลจิสติกส์ จำกัด", + }, + ], + }, + { + slug: "facebook-lost-price", + title: "Lead lost to competitor because price too high", + customerName: "บริษัท ไทย ออโต้ เพรส จำกัด", + province: "Samut Prakan", + district: "Bang Phli", + branchCode: "factory", + customerGroupCode: "industrial", + customerSubGroupCode: "factory", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "normal", + leadChannelCode: "facebook", + awarenessCode: "social_media", + leadStatusCode: "closed_lost", + leadFollowupStatusCode: "quotation_preparation", + opportunityStatusCode: "quotation_created", + chancePercent: 45, + estimatedValue: 5800000, + projectName: "Stamping Line Hoist Replacement", + projectLocation: "Bang Phli Industrial Estate", + leadDescription: + "Customer compared 5 ton monorail hoist replacement between ALLA and Konecranes.", + competitor: "Konecranes", + quotePlan: "sent_follow_up", + opportunityOutcome: "lost", + lostReasonCode: "price_too_high", + }, + { + slug: "revision-r02-approved", + title: "Customer requested revision and approved revision R02", + customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด", + province: "Pathum Thani", + district: "Khlong Luang", + branchCode: "service", + customerGroupCode: "industrial", + customerSubGroupCode: "logistics", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", + priorityCode: "high", + leadChannelCode: "referral", + awarenessCode: "word_of_mouth", + leadStatusCode: "assigned", + leadFollowupStatusCode: "waiting_po", + opportunityStatusCode: "negotiation", + chancePercent: 70, + estimatedValue: 9300000, + projectName: "Cold Chain Dock Leveller Upgrade", + projectLocation: "Khlong Luang Logistics Campus", + leadDescription: + "Customer requested revision after consultant changed dock layout and bumper specification.", + quotePlan: "rejected_then_revised", + opportunityOutcome: "won", + multiParties: [ + { + roleCode: "consultant", + customerName: "บริษัท เอ็นจิเนียริ่ง คอนเซ็ปท์ คอนซัลแตนท์ จำกัด", + }, + ], + }, + { + slug: "no-quotation-cancelled-project", + title: "No quotation because customer cancelled project", + customerName: "มหาวิทยาลัยเทคโนโลยีธนบุรี", + province: "Bangkok", + district: "Thung Khru", + branchCode: "head_office", + customerGroupCode: "government", + customerSubGroupCode: "government_agency", + productTypeCode: "solarcell", + quotationTypeCode: "solarcell", + priorityCode: "low", + leadChannelCode: "referral", + awarenessCode: "introducing", + leadStatusCode: "cancel", + leadFollowupStatusCode: "budget_pending", + opportunityStatusCode: "qualification", + chancePercent: 15, + estimatedValue: 2200000, + projectName: "Solar Rooftop for Engineering Building", + projectLocation: "Bangmod Campus", + leadDescription: + "Procurement advised project was frozen after university budget allocation changed.", + quotePlan: "cancelled", + opportunityOutcome: "no_quotation", + noQuotationReasonCode: "budget_not_ready", + }, + { + slug: "large-project-multi-party", + title: "Large project with multiple project parties", + customerName: "บริษัท อีสเทิร์น เพาเวอร์ ซิสเต็ม จำกัด", + province: "Rayong", + district: "Pluak Daeng", + branchCode: "factory", + customerGroupCode: "industrial", + customerSubGroupCode: "factory", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "urgent", + leadChannelCode: "sales", + awarenessCode: "visit", + leadStatusCode: "assigned", + leadFollowupStatusCode: "drawing_followup", + opportunityStatusCode: "proposal_preparation", + chancePercent: 75, + estimatedValue: 35200000, + projectName: "Turbine Hall Double Girder Crane", + projectLocation: "Rayong Power Plant Block C", + leadDescription: + "EPC requested a high-value crane package with consultant review and separate billing customer.", + quotePlan: "approved", + opportunityOutcome: "open", + multiParties: [ + { + roleCode: "billing_customer", + customerName: "บริษัท พาวเวอร์ อี.พี.ซี. (ไทยแลนด์) จำกัด", + }, + { + roleCode: "contractor", + customerName: "บริษัท ไทย เทอร์ไบน์ คอนสตรัคชั่น จำกัด", + }, + { + roleCode: "consultant", + customerName: "บริษัท พาวเวอร์ ดีไซน์ แอดไวเซอรี่ จำกัด", + }, + { + roleCode: "end_customer", + customerName: "บริษัท อีสเทิร์น ยูทิลิตี้ โฮลดิ้ง จำกัด", + }, + ], + }, + { + slug: "waiting-approval-sales-manager", + title: "Quotation waiting approval at sales manager", + customerName: "บริษัท นครคลังสินค้า จำกัด", + province: "Ayutthaya", + district: "Wang Noi", + branchCode: "service", + customerGroupCode: "industrial", + customerSubGroupCode: "warehouse", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", + priorityCode: "normal", + leadChannelCode: "other", + awarenessCode: "word_of_mouth", + leadStatusCode: "assigned", + leadFollowupStatusCode: "quotation_preparation", + opportunityStatusCode: "quotation_created", + chancePercent: 55, + estimatedValue: 4100000, + projectName: "Dock Shelter Retrofit Program", + projectLocation: "Wang Noi Distribution Center", + leadDescription: + "Customer asked for fast-track retrofit but margin requires internal approval.", + quotePlan: "pending_step_1", + opportunityOutcome: "open", + }, + { + slug: "approval-returned-r03", + title: "Approval returned and revised again until R03", + customerName: "บริษัท เมโทร โครงการเชิงพาณิชย์ จำกัด", + province: "Bangkok", + district: "Huai Khwang", + branchCode: "head_office", + customerGroupCode: "construction", + customerSubGroupCode: "contractor", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", + priorityCode: "high", + leadChannelCode: "sales", + awarenessCode: "visit", + leadStatusCode: "assigned", + leadFollowupStatusCode: "spec_clarification", + opportunityStatusCode: "proposal_preparation", + chancePercent: 62, + estimatedValue: 6900000, + projectName: "Commercial Tower Loading Bay Package", + projectLocation: "Rama 9 Mixed Use Development", + leadDescription: + "Manager returned quotation because payment term and warranty wording had to be updated.", + quotePlan: "returned_then_revised", + opportunityOutcome: "open", + multiParties: [ + { + roleCode: "consultant", + customerName: "บริษัท ออร์บิท ดีไซน์ คอนซัลแทนท์ จำกัด", + }, + { + roleCode: "end_customer", + customerName: "บริษัท เมโทร แอสเสท ดีเวลลอปเมนท์ จำกัด", + }, + ], + }, + { + slug: "followup-overdue", + title: "Overdue follow-up for salesperson handoff", + customerName: "บริษัท สมุทรเครื่องกล จำกัด", + province: "Samut Sakhon", + district: "Mueang Samut Sakhon", + branchCode: "service", + customerGroupCode: "industrial", + customerSubGroupCode: "factory", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "normal", + leadChannelCode: "line", + awarenessCode: "repurchasing", + leadStatusCode: "follow_up", + leadFollowupStatusCode: "site_survey_waiting", + opportunityStatusCode: "site_survey", + chancePercent: 38, + estimatedValue: 3600000, + projectName: "Production Bay Jib Crane", + projectLocation: "Samut Sakhon Seafood Plant", + leadDescription: + "Site survey was discussed but salesperson missed the agreed follow-up date.", + quotePlan: "sent_follow_up", + opportunityOutcome: "open", + }, + { + slug: "high-value-hot-project", + title: "High value project tracked as hot project", + customerName: "บริษัท อยุธยา ออโตโมทีฟ พาร์ท จำกัด", + province: "Ayutthaya", + district: "Uthai", + branchCode: "factory", + customerGroupCode: "industrial", + customerSubGroupCode: "factory", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "urgent", + leadChannelCode: "other", + awarenessCode: "exhibition_event", + leadStatusCode: "assigned", + leadFollowupStatusCode: "waiting_po", + opportunityStatusCode: "negotiation", + chancePercent: 80, + estimatedValue: 35000000, + projectName: "Press Shop Smart Crane Upgrade", + projectLocation: "Rojana Industrial Park", + leadDescription: + "Board approved budget and customer is evaluating final commercial conditions.", + competitor: "Demag", + quotePlan: "accepted", + opportunityOutcome: "won", + }, + { + slug: "existing-customer-repeat-order", + title: "Existing customer repeat order with fast approval", + customerName: "บริษัท อัลฟ่า โลจิสติกส์ เซอร์วิส จำกัด", + province: "Chonburi", + district: "Si Racha", + branchCode: "service", + customerGroupCode: "industrial", + customerSubGroupCode: "logistics", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", + priorityCode: "high", + leadChannelCode: "sales", + awarenessCode: "repurchasing", + leadStatusCode: "assigned", + leadFollowupStatusCode: "waiting_po", + opportunityStatusCode: "negotiation", + chancePercent: 88, + estimatedValue: 5100000, + projectName: "Distribution Hub Dock Door Expansion", + projectLocation: "Laem Chabang Logistics Hub", + leadDescription: + "Repeat order from an existing logistics customer who requested similar specification.", + quotePlan: "approved", + opportunityOutcome: "won", + }, + { + slug: "government-budget-delay", + title: "Government project stalled by budget delay", + customerName: "องค์การบริหารส่วนจังหวัดนนทบุรี", + province: "Nonthaburi", + district: "Mueang Nonthaburi", + branchCode: "head_office", + customerGroupCode: "government", + customerSubGroupCode: "government_agency", + productTypeCode: "solarcell", + quotationTypeCode: "solarcell", + priorityCode: "low", + leadChannelCode: "referral", + awarenessCode: "brochure_catalog", + leadStatusCode: "follow_up", + leadFollowupStatusCode: "budget_pending", + opportunityStatusCode: "qualification", + chancePercent: 20, + estimatedValue: 7800000, + projectName: "Provincial Office Solar Carport", + projectLocation: "Nonthaburi Government Complex", + leadDescription: + "Customer wants to continue but budget approval moved to next fiscal year.", + quotePlan: "sent_follow_up", + opportunityOutcome: "open", + }, + { + slug: "consultant-spec-mismatch-lost", + title: "Lost because consultant specification changed", + customerName: "บริษัท เซ็นทรัล เมดิคอล เซ็นเตอร์ จำกัด", + province: "Bangkok", + district: "Chatuchak", + branchCode: "head_office", + customerGroupCode: "other", + customerSubGroupCode: "other", + productTypeCode: "dockdoor", + quotationTypeCode: "dockdoor", + priorityCode: "normal", + leadChannelCode: "website", + awarenessCode: "google_search_website", + leadStatusCode: "closed_lost", + leadFollowupStatusCode: "spec_clarification", + opportunityStatusCode: "proposal_preparation", + chancePercent: 35, + estimatedValue: 2950000, + projectName: "Hospital Sterile Delivery Door Package", + projectLocation: "Central Medical Campus", + leadDescription: + "Consultant switched to another system after infection-control requirement changed.", + quotePlan: "rejected_then_revised", + opportunityOutcome: "lost", + lostReasonCode: "spec_not_match", + multiParties: [ + { + roleCode: "consultant", + customerName: "บริษัท เฮลธ์แคร์ โปรเจค คอนซัลแตนท์ จำกัด", + }, + ], + }, + { + slug: "dealer-channel-approved", + title: + "Dealer channel opportunity approved and waiting customer acceptance", + customerName: "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", + province: "Chiang Mai", + district: "Mueang Chiang Mai", + branchCode: "head_office", + customerGroupCode: "dealer", + customerSubGroupCode: "reseller", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "normal", + leadChannelCode: "referral", + awarenessCode: "introducing", + leadStatusCode: "assigned", + leadFollowupStatusCode: "quotation_preparation", + opportunityStatusCode: "quotation_created", + chancePercent: 58, + estimatedValue: 4700000, + projectName: "Dealer Stock Crane Package", + projectLocation: "Chiang Mai Service Warehouse", + leadDescription: + "Reseller requested a standard package for a northern distribution project.", + quotePlan: "approved", + opportunityOutcome: "open", + }, + { + slug: "line-maintenance-renewal", + title: "Maintenance renewal tracked through quotation and acceptance", + customerName: "บริษัท เซาท์พอร์ต โลจิสติกส์ จำกัด", + province: "Songkhla", + district: "Hat Yai", + branchCode: "service", + customerGroupCode: "industrial", + customerSubGroupCode: "warehouse", + productTypeCode: "dockdoor", + quotationTypeCode: "service", + priorityCode: "normal", + leadChannelCode: "referral", + awarenessCode: "repurchasing", + leadStatusCode: "assigned", + leadFollowupStatusCode: "waiting_po", + opportunityStatusCode: "negotiation", + chancePercent: 76, + estimatedValue: 1650000, + projectName: "Annual Dock Door Maintenance Contract", + projectLocation: "Hat Yai Distribution Center", + leadDescription: + "Existing customer requested annual maintenance renewal and safety inspection package.", + quotePlan: "accepted", + opportunityOutcome: "won", + }, + { + slug: "phone-call-cancelled", + title: "Phone call lead cancelled after customer internal hold", + customerName: "บริษัท เวสเทิร์น คอมเมอร์เชียล บิลดิ้ง จำกัด", + province: "Nakhon Pathom", + district: "Sam Phran", + branchCode: "head_office", + customerGroupCode: "other", + customerSubGroupCode: "other", + productTypeCode: "solarcell", + quotationTypeCode: "solarcell", + priorityCode: "low", + leadChannelCode: "other", + awarenessCode: "word_of_mouth", + leadStatusCode: "cancel", + leadFollowupStatusCode: "project_under_review", + opportunityStatusCode: "qualification", + chancePercent: 12, + estimatedValue: 1950000, + projectName: "Commercial Roof Solar Feasibility", + projectLocation: "Sam Phran Office Complex", + leadDescription: + "Customer requested to pause project indefinitely after holding company review.", + quotePlan: "cancelled", + opportunityOutcome: "cancelled", + cancelReasonCode: "internal_hold", + }, + { + slug: "university-lab-crane-approved", + title: "University lab crane project approved and sent", + customerName: "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ", + province: "Bangkok", + district: "Bang Sue", + branchCode: "head_office", + customerGroupCode: "government", + customerSubGroupCode: "government_agency", + productTypeCode: "crane", + quotationTypeCode: "crane", + priorityCode: "normal", + leadChannelCode: "other", + awarenessCode: "exhibition_event", + leadStatusCode: "assigned", + leadFollowupStatusCode: "quotation_preparation", + opportunityStatusCode: "quotation_created", + chancePercent: 52, + estimatedValue: 3200000, + projectName: "Material Handling Lab Crane", + projectLocation: "Bang Sue Engineering Faculty", + leadDescription: + "Faculty requested teaching-lab crane and asked for formal quotation after exhibition meeting.", + quotePlan: "pending_step_2", + opportunityOutcome: "open", + }, +]; + +function loadEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) return; + + const content = fs.readFileSync(filePath, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const separatorIndex = line.indexOf("="); + if (separatorIndex === -1) continue; + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), ".env.local")); + loadEnvFile(path.resolve(process.cwd(), ".env")); +} + +function requireEnv(name: string) { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function slugify(input: string) { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} + +function buildDocumentCode(prefix: string, number: number) { + return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`; +} + +function seededNote(scope: string) { + return `[${SEED_TAG}:${scope}]`; +} + +function daysAgo(days: number, hour = 9) { + return new Date(Date.UTC(2026, 5, 25 - days, hour, 0, 0)); +} + +function daysAhead(days: number, hour = 9) { + return new Date(Date.UTC(2026, 5, 25 + days, hour, 0, 0)); +} + +function addDays(base: Date, days: number, hour = 9) { + return new Date( + Date.UTC( + base.getUTCFullYear(), + base.getUTCMonth(), + base.getUTCDate() + days, + hour, + 0, + 0, + ), + ); +} + +function choose(items: readonly T[], index: number) { + return items[index % items.length]; +} + +function inferCustomerIndustryLabel(story: StoryConfig) { + if (story.customerGroupCode === "government") return "Government"; + if (story.customerSubGroupCode === "warehouse") return "Warehouse"; + if (story.customerSubGroupCode === "logistics") return "Logistics"; + if (story.customerSubGroupCode === "factory") return "Factory"; + if (story.customerSubGroupCode === "contractor") return "Contractor"; + if (story.customerSubGroupCode === "consultant") return "Consultant"; + if (story.customerGroupCode === "dealer") return "Dealer"; + return "Commercial"; +} + +function getOptionId( + optionMap: Map, + category: string, + code: string, +) { + const key = `${category}:${code}`; + const value = optionMap.get(key)?.id; + if (!value) { + throw new Error(`Missing option ${key}`); + } + + return value; +} + +function getOptionalOptionId( + optionMap: Map, + category: string, + code: string, +) { + return optionMap.get(`${category}:${code}`)?.id ?? null; +} + +async function loadOrganizations(sql: SqlClient): Promise { + return sql` + select id, name, created_by as "createdBy" + from organizations + order by name asc + `; +} + +async function loadMemberships( + sql: SqlClient, + organizationId: string, +): Promise { + return sql` + select + m.user_id as "userId", + u.name as "userName", + u.email, + u.system_role as "systemRole", + m.role as "membershipRole", + m.business_role as "businessRole" + from memberships m + inner join users u on u.id = m.user_id + where m.organization_id = ${organizationId} + order by u.name asc nulls last, u.email asc + `; +} + +async function loadOptions( + sql: SqlClient, + organizationId: string, +): Promise> { + const rows = await sql` + select id, category, code, label, parent_id as "parentId" + from ms_options + where organization_id = ${organizationId} + and deleted_at is null + and is_active = true + `; + + return new Map(rows.map((row) => [`${row.category}:${row.code}`, row])); +} + +async function loadBranches( + sql: SqlClient, + organizationId: string, +): Promise { + return sql` + select id, code, label + from ms_options + where organization_id = ${organizationId} + and category = 'crm_branch' + and deleted_at is null + and is_active = true + order by sort_order asc, label asc + `; +} + +async function resolveApprovalWorkflow(sql: SqlClient, organizationId: string) { + const [workflow] = await sql<{ id: string }[]>` + select id + from crm_approval_workflows + where organization_id = ${organizationId} + and code = 'quotation_standard_approval' + and deleted_at is null + and is_active = true + limit 1 + `; + + if (!workflow) { + throw new Error( + "Approval workflow quotation_standard_approval is not configured", + ); + } + + return workflow.id; +} + +function buildUserContext(rows: UserMembershipRow[]): UserContext { + if (rows.length === 0) { + throw new Error("Organization has no memberships"); + } + + const first = rows[0]; + const byRole = (role: string) => + rows.find((row) => row.businessRole === role) ?? null; + const salesRows = rows.filter((row) => row.businessRole === "sales"); + const admin = + rows.find((row) => row.membershipRole === "admin") ?? + rows.find((row) => row.systemRole === "super_admin") ?? + first; + + return { + admin, + marketing: byRole("sales_support") ?? admin, + sales: salesRows.length ? salesRows : [admin], + salesManager: byRole("sales_manager") ?? admin, + departmentManager: byRole("department_manager") ?? admin, + topManager: byRole("top_manager") ?? admin, + }; +} + +async function cleanupSeededData(sql: SqlClient, organizationId: string) { + const quotationRows = await sql<{ id: string }[]>` + select id + from crm_quotations + where organization_id = ${organizationId} + and reference like ${`%${SEED_TAG}%`} + and deleted_at is null + `; + const opportunityRows = await sql<{ id: string }[]>` + select id + from crm_opportunities + where organization_id = ${organizationId} + and source like ${`${SEED_TAG}:%`} + and deleted_at is null + `; + const leadRows = await sql<{ id: string }[]>` + select id + from crm_leads + where organization_id = ${organizationId} + and description like ${`%${SEED_TAG}%`} + and deleted_at is null + `; + const customerRows = await sql<{ id: string }[]>` + select id + from crm_customers + where organization_id = ${organizationId} + and notes like ${`%${SEED_TAG}%`} + and deleted_at is null + `; + + const quotationIds = quotationRows.map((row) => row.id); + const opportunityIds = opportunityRows.map((row) => row.id); + const leadIds = leadRows.map((row) => row.id); + const customerIds = customerRows.map((row) => row.id); + + if (quotationIds.length) { + await sql`delete from crm_approval_actions where organization_id = ${organizationId} and approval_request_id in ( + select id from crm_approval_requests where organization_id = ${organizationId} and entity_type = 'quotation' and entity_id = any(${sql.array(quotationIds)}) + )`; + await sql`delete from crm_approval_requests where organization_id = ${organizationId} and entity_type = 'quotation' and entity_id = any(${sql.array(quotationIds)})`; + await sql`delete from crm_quotation_topic_items where organization_id = ${organizationId} and topic_id in ( + select id from crm_quotation_topics where organization_id = ${organizationId} and quotation_id = any(${sql.array(quotationIds)}) + )`; + await sql`delete from crm_quotation_topics where organization_id = ${organizationId} and quotation_id = any(${sql.array(quotationIds)})`; + await sql`delete from crm_quotation_items where organization_id = ${organizationId} and quotation_id = any(${sql.array(quotationIds)})`; + await sql`delete from crm_quotation_customers where organization_id = ${organizationId} and quotation_id = any(${sql.array(quotationIds)})`; + await sql`delete from crm_quotation_followups where organization_id = ${organizationId} and quotation_id = any(${sql.array(quotationIds)})`; + await sql`delete from crm_quotations where organization_id = ${organizationId} and id = any(${sql.array(quotationIds)})`; + } + + if (opportunityIds.length) { + await sql`delete from crm_opportunity_customers where organization_id = ${organizationId} and opportunity_id = any(${sql.array(opportunityIds)})`; + await sql`delete from crm_opportunity_followups where organization_id = ${organizationId} and opportunity_id = any(${sql.array(opportunityIds)})`; + await sql`delete from crm_opportunities where organization_id = ${organizationId} and id = any(${sql.array(opportunityIds)})`; + } + + if (leadIds.length) { + await sql`delete from tr_audit_logs where organization_id = ${organizationId} and entity_type = 'crm_lead' and entity_id = any(${sql.array(leadIds)})`; + await sql`delete from crm_leads where organization_id = ${organizationId} and id = any(${sql.array(leadIds)})`; + } + + if (customerIds.length) { + await sql`delete from crm_contact_shares where organization_id = ${organizationId} and contact_id in ( + select id from crm_customer_contacts where organization_id = ${organizationId} and customer_id = any(${sql.array(customerIds)}) + )`; + await sql`delete from crm_customer_owner_history where organization_id = ${organizationId} and customer_id = any(${sql.array(customerIds)})`; + await sql`delete from crm_customer_contacts where organization_id = ${organizationId} and customer_id = any(${sql.array(customerIds)})`; + await sql`delete from crm_customers where organization_id = ${organizationId} and id = any(${sql.array(customerIds)})`; + } +} + +function buildBaseCustomers( + optionMap: Map, + branches: BranchRow[], + users: UserContext, +) { + const customers: SeedCustomer[] = []; + const storyCustomerMap = new Map(); + let customerSequence = 801; + + for (const story of STORIES) { + const branch = + branches.find((row) => row.code === story.branchCode) ?? + branches[0] ?? + null; + const customer: SeedCustomer = { + id: crypto.randomUUID(), + code: buildDocumentCode("CUS", customerSequence++), + name: story.customerName, + branchId: branch?.id ?? null, + province: story.province, + district: story.district, + customerTypeId: getOptionId(optionMap, "crm_customer_type", "company"), + customerStatusId: getOptionId(optionMap, "crm_customer_status", "active"), + customerGroupId: getOptionId( + optionMap, + "crm_customer_group", + story.customerGroupCode, + ), + customerSubGroupId: getOptionId( + optionMap, + "crm_customer_sub_group", + story.customerSubGroupCode, + ), + leadChannelId: getOptionalOptionId( + optionMap, + "crm_lead_channel", + story.leadChannelCode, + ), + ownerUserId: choose(users.sales, customers.length).userId, + notes: `${seededNote(story.slug)} Story: ${story.title}. Industry: ${inferCustomerIndustryLabel(story)}.`, + createdBy: users.admin.userId, + email: `${slugify(story.customerName)}@example.co.th`, + phone: `02-${String(400000 + customerSequence).slice(-6)}`, + website: `https://${slugify(story.customerName)}.example.co.th`, + }; + + customers.push(customer); + storyCustomerMap.set(story.slug, customer); + + for (const party of story.multiParties ?? []) { + customers.push({ + id: crypto.randomUUID(), + code: buildDocumentCode("CUS", customerSequence++), + name: party.customerName, + branchId: branch?.id ?? null, + province: story.province, + district: story.district, + customerTypeId: getOptionId(optionMap, "crm_customer_type", "company"), + customerStatusId: getOptionId( + optionMap, + "crm_customer_status", + "active", + ), + customerGroupId: getOptionId( + optionMap, + "crm_customer_group", + story.customerGroupCode, + ), + customerSubGroupId: getOptionId( + optionMap, + "crm_customer_sub_group", + story.customerSubGroupCode, + ), + leadChannelId: getOptionalOptionId( + optionMap, + "crm_lead_channel", + story.leadChannelCode, + ), + ownerUserId: choose(users.sales, customerSequence).userId, + notes: `${seededNote(`${story.slug}-${party.roleCode}`)} Project party for ${story.customerName}.`, + createdBy: users.admin.userId, + email: `${slugify(party.customerName)}@example.co.th`, + phone: `02-${String(500000 + customerSequence).slice(-6)}`, + website: `https://${slugify(party.customerName)}.example.co.th`, + }); + } + } + + const extraTemplates = [ + [ + "บริษัท พรีเมียม แมนูแฟคเจอริ่ง โซลูชั่น จำกัด", + "industrial", + "factory", + "factory", + ], + [ + "บริษัท สยาม คลังสินค้าสมัยใหม่ จำกัด", + "industrial", + "warehouse", + "service", + ], + [ + "บริษัท ไทย รีเทล ซัพพลาย เชน จำกัด", + "industrial", + "logistics", + "service", + ], + [ + "บริษัท เมโทร อินฟรา คอนสตรัคชั่น จำกัด", + "construction", + "contractor", + "head_office", + ], + [ + "บริษัท แอดวานซ์ โปรเจค คอนซัลแทนท์ จำกัด", + "construction", + "consultant", + "head_office", + ], + [ + "การไฟฟ้าส่วนภูมิภาค เขตภาคกลาง", + "government", + "state_enterprise", + "head_office", + ], + [ + "บริษัท อินโนเวทีฟ แคมปัส ฟาซิลิตี้ จำกัด", + "other", + "other", + "head_office", + ], + ["บริษัท อุตสาหกรรมอาหารเหนือ จำกัด", "industrial", "factory", "factory"], + [ + "บริษัท เวิลด์ไวด์ ดิสทริบิวชัน เซ็นเตอร์ จำกัด", + "industrial", + "logistics", + "service", + ], + ["บริษัท ออโต้โมชั่น ซิสเต็มส์ จำกัด", "industrial", "factory", "factory"], + ["บริษัท ไททัน พอร์ต เซอร์วิส จำกัด", "industrial", "warehouse", "service"], + [ + "บริษัท เมืองใหม่ คอมเมอร์เชียล เซ็นเตอร์ จำกัด", + "other", + "other", + "head_office", + ], + [ + "บริษัท พรีเมียร์ โพรคิวร์เมนต์ ฮับ จำกัด", + "dealer", + "reseller", + "head_office", + ], + ["บริษัท เอเชีย เมทัล เวิร์คส์ จำกัด", "industrial", "factory", "factory"], + [ + "บริษัท โกลเด้น ฟาร์ม โลจิสติกส์ จำกัด", + "industrial", + "logistics", + "service", + ], + [ + "บริษัท บางนา คูลเชน เซอร์วิส จำกัด", + "industrial", + "warehouse", + "service", + ], + [ + "บริษัท สยาม โปรเจค ดีไซน์ จำกัด", + "construction", + "consultant", + "head_office", + ], + [ + "บริษัท ยูไนเต็ด บิลดิ้ง โปรดักส์ จำกัด", + "construction", + "contractor", + "head_office", + ], + ] as const; + + for (const [name, groupCode, subGroupCode, branchCode] of extraTemplates) { + if (customers.length >= 50) break; + const branch = + branches.find((row) => row.code === branchCode) ?? branches[0] ?? null; + customers.push({ + id: crypto.randomUUID(), + code: buildDocumentCode("CUS", customerSequence++), + name, + branchId: branch?.id ?? null, + province: choose(STORIES, customerSequence).province, + district: choose(STORIES, customerSequence).district, + customerTypeId: getOptionId(optionMap, "crm_customer_type", "company"), + customerStatusId: getOptionId(optionMap, "crm_customer_status", "active"), + customerGroupId: getOptionId(optionMap, "crm_customer_group", groupCode), + customerSubGroupId: getOptionId( + optionMap, + "crm_customer_sub_group", + subGroupCode, + ), + leadChannelId: getOptionalOptionId( + optionMap, + "crm_lead_channel", + "referral", + ), + ownerUserId: choose(users.sales, customerSequence).userId, + notes: `${seededNote(`customer-${customerSequence}`)} Supporting UAT account for dashboard and report coverage.`, + createdBy: users.admin.userId, + email: `${slugify(name)}@example.co.th`, + phone: `02-${String(600000 + customerSequence).slice(-6)}`, + website: `https://${slugify(name)}.example.co.th`, + }); + } + + return { customers, storyCustomerMap }; +} + +function buildContacts(customers: SeedCustomer[], users: UserContext) { + const positions = [ + ["Procurement Manager", "Procurement"], + ["Project Engineer", "Engineering"], + ["Plant Manager", "Operations"], + ["Maintenance Supervisor", "Maintenance"], + ["Warehouse Manager", "Logistics"], + ] as const; + + const contacts: SeedContact[] = []; + const primaryContactByCustomerId = new Map(); + + for (const [index, customer] of customers.entries()) { + const count = 2 + (index % 4); + for (let offset = 0; offset < count; offset += 1) { + const [position, department] = choose(positions, index + offset); + const name = `${offset === 0 ? "คุณ" : "คุณ"}${["กิตติพงศ์", "ศิริพร", "ธนพล", "วราภรณ์", "ปิยะนุช", "จักรพันธ์"][(index * 3 + offset) % 6]} ${["แสงทอง", "สิริวัฒน์", "สุวรรณ", "จิรโรจน์", "อนันต์สุข", "เกษมศรี"][(index * 5 + offset) % 6]}`; + const contact: SeedContact = { + id: crypto.randomUUID(), + customerId: customer.id, + name, + position, + department, + phone: customer.phone, + mobile: `08${String(10000000 + index * 10 + offset).slice(-8)}`, + email: `${slugify(name)}@${slugify(customer.name)}.co.th`, + isPrimary: offset === 0, + notes: `${seededNote(`contact-${customer.code}-${offset + 1}`)} ${department} contact for ${customer.name}.`, + createdBy: users.admin.userId, + }; + contacts.push(contact); + if (offset === 0) { + primaryContactByCustomerId.set(customer.id, contact); + } + } + } + + return { contacts, primaryContactByCustomerId }; +} + +function buildLeadAndOpportunityData( + optionMap: Map, + storyCustomerMap: Map, + allCustomers: SeedCustomer[], + primaryContactByCustomerId: Map, + users: UserContext, +) { + const leads: SeedLead[] = []; + const leadFollowups: SeedLeadFollowup[] = []; + const opportunities: SeedOpportunity[] = []; + const opportunityParties: SeedOpportunityParty[] = []; + const opportunityFollowups: SeedOpportunityFollowup[] = []; + const storyOpportunityMap = new Map(); + + let leadSequence = 901; + let opportunitySequence = 1001; + + for (const [storyIndex, story] of STORIES.entries()) { + const customer = storyCustomerMap.get(story.slug); + if (!customer) { + throw new Error(`Story customer missing for ${story.slug}`); + } + const contact = primaryContactByCustomerId.get(customer.id) ?? null; + const createdAt = daysAgo(88 - storyIndex * 4); + const assignedUser = choose(users.sales, storyIndex); + const assignedAt = addDays(createdAt, 4); + const statusId = getOptionId( + optionMap, + "crm_lead_status", + story.leadStatusCode, + ); + const followupStatusId = getOptionalOptionId( + optionMap, + "crm_lead_followup_status", + story.leadFollowupStatusCode, + ); + const lostReasonId = story.lostReasonCode + ? getOptionalOptionId( + optionMap, + "crm_lead_lost_reason", + story.lostReasonCode, + ) + : null; + const lead: SeedLead = { + id: crypto.randomUUID(), + code: buildDocumentCode("LD", leadSequence++), + customerId: customer.id, + contactId: contact?.id ?? null, + branchId: customer.branchId, + description: `${story.leadDescription} ${seededNote(story.slug)}`, + projectName: story.projectName, + projectLocation: story.projectLocation, + productTypeId: getOptionalOptionId( + optionMap, + "crm_product_type", + story.productTypeCode, + ), + priorityId: getOptionalOptionId( + optionMap, + "crm_priority", + story.priorityCode, + ), + estimatedValue: story.estimatedValue, + awarenessId: getOptionalOptionId( + optionMap, + "crm_lead_awareness", + story.awarenessCode, + ), + statusId, + followupStatusId, + lostReasonId, + outcome: + story.opportunityOutcome === "won" + ? "won" + : story.opportunityOutcome === "lost" + ? "lost" + : "open", + ownerMarketingUserId: users.marketing.userId, + assignedSalesOwnerId: ["assigned", "closed_lost", "cancel"].includes( + story.leadStatusCode, + ) + ? assignedUser.userId + : null, + assignedAt: ["assigned", "closed_lost", "cancel"].includes( + story.leadStatusCode, + ) + ? assignedAt + : null, + assignedBy: ["assigned", "closed_lost", "cancel"].includes( + story.leadStatusCode, + ) + ? users.marketing.userId + : null, + assignmentRemark: ["assigned", "closed_lost", "cancel"].includes( + story.leadStatusCode, + ) + ? `Marketing handed over story ${story.slug} to sales.` + : null, + createdBy: users.marketing.userId, + createdAt, + }; + leads.push(lead); + + const leadFollowupCount = story.leadStatusCode === "new_job" ? 1 : 2; + for ( + let followupIndex = 0; + followupIndex < leadFollowupCount; + followupIndex += 1 + ) { + const followupDate = addDays(createdAt, 2 + followupIndex * 6); + leadFollowups.push({ + id: crypto.randomUUID(), + leadId: lead.id, + branchId: lead.branchId, + userId: + followupIndex === 0 ? users.marketing.userId : assignedUser.userId, + followupDate, + followupStatusId: + followupStatusId ?? + getOptionId(optionMap, "crm_lead_followup_status", "budget_pending"), + note: + followupIndex === 0 + ? "Initial qualification completed and customer timeline confirmed." + : "Followed up on pricing, scope, and next commercial action.", + nextFollowupDate: + followupIndex === leadFollowupCount - 1 + ? null + : addDays(followupDate, 7), + createdAt: addDays(followupDate, 0, 14), + }); + } + + const openStage = + story.opportunityOutcome === "won" + ? "closed_won" + : story.opportunityOutcome === "lost" + ? "closed_lost" + : story.opportunityOutcome === "open" + ? storyIndex % 3 === 0 + ? "lead" + : "opportunity" + : "opportunity"; + + const opportunityCreatedAt = addDays(createdAt, 8); + const closedAt = + story.opportunityOutcome === "open" + ? null + : addDays(opportunityCreatedAt, 25 + (storyIndex % 8)); + const opportunity: SeedOpportunity = { + id: crypto.randomUUID(), + code: buildDocumentCode("EN", opportunitySequence++), + customerId: customer.id, + contactId: contact?.id ?? null, + leadId: lead.id, + branchId: customer.branchId, + title: story.projectName, + description: story.leadDescription, + requirement: `Need proposal for ${story.projectName.toLowerCase()} with timeline, commercial scope, and installation plan.`, + projectName: story.projectName, + projectLocation: story.projectLocation, + productTypeId: getOptionId( + optionMap, + "crm_product_type", + story.productTypeCode, + ), + statusId: getOptionId( + optionMap, + "crm_opportunity_stage", + story.opportunityStatusCode, + ), + outcomeStatus: story.opportunityOutcome, + priorityId: getOptionId(optionMap, "crm_priority", story.priorityCode), + leadChannelId: getOptionalOptionId( + optionMap, + "crm_lead_channel", + story.leadChannelCode, + ), + estimatedValue: story.estimatedValue, + chancePercent: story.chancePercent, + expectedCloseDate: + story.opportunityOutcome === "open" + ? addDays(opportunityCreatedAt, 21) + : closedAt, + competitor: story.competitor ?? null, + source: `${SEED_TAG}:${story.slug}`, + notes: `${seededNote(story.slug)} Opportunity seeded for story coverage.`, + isHotProject: + story.estimatedValue >= 10000000 || story.priorityCode === "urgent", + isActive: true, + pipelineStage: openStage, + closedAt, + closedWonAt: story.opportunityOutcome === "won" ? closedAt : null, + closedLostAt: story.opportunityOutcome === "lost" ? closedAt : null, + closedByUserId: closedAt ? users.salesManager.userId : null, + poNumber: + story.opportunityOutcome === "won" + ? `PO-${String(2606000 + storyIndex + 1).padStart(7, "0")}` + : null, + poDate: + story.opportunityOutcome === "won" ? addDays(closedAt!, -1) : null, + poAmount: + story.opportunityOutcome === "won" + ? Math.round(story.estimatedValue * (0.95 + (storyIndex % 4) * 0.03)) + : null, + poCurrencyId: + story.opportunityOutcome === "won" + ? getOptionId(optionMap, "crm_currency", "THB") + : null, + lostReasonId: story.lostReasonCode + ? getOptionalOptionId( + optionMap, + "crm_opportunity_lost_reason", + story.lostReasonCode, + ) + : null, + lostDetail: + story.opportunityOutcome === "lost" + ? "Customer benchmarked price and competitor lead time." + : null, + lostCompetitor: + story.opportunityOutcome === "lost" + ? (story.competitor ?? "Konecranes") + : null, + lostRemark: + story.opportunityOutcome === "lost" + ? "Closed after customer confirmed competitor selection." + : null, + cancelReasonId: story.cancelReasonCode + ? getOptionalOptionId( + optionMap, + "crm_opportunity_cancel_reason", + story.cancelReasonCode, + ) + : null, + noQuotationReasonId: story.noQuotationReasonCode + ? getOptionalOptionId( + optionMap, + "crm_opportunity_no_quotation_reason", + story.noQuotationReasonCode, + ) + : null, + assignedToUserId: openStage === "lead" ? null : assignedUser.userId, + assignedAt: + openStage === "lead" ? null : addDays(opportunityCreatedAt, 0, 11), + assignedBy: openStage === "lead" ? null : users.salesManager.userId, + assignmentRemark: + openStage === "lead" ? null : `Assigned from lead ${lead.code}`, + createdBy: assignedUser.userId, + updatedBy: assignedUser.userId, + createdAt: opportunityCreatedAt, + updatedAt: closedAt ?? addDays(opportunityCreatedAt, 14), + }; + opportunities.push(opportunity); + storyOpportunityMap.set(story.slug, opportunity); + + opportunityFollowups.push({ + id: crypto.randomUUID(), + opportunityId: opportunity.id, + contactId: contact?.id ?? null, + followupDate: addDays(opportunityCreatedAt, 5), + followupTypeId: getOptionId(optionMap, "crm_followup_type", "meeting"), + outcome: "Requirement confirmed and next engineering action assigned.", + notes: "Reviewed layout, lifting capacity, and commercial target.", + nextFollowupDate: addDays(opportunityCreatedAt, 12), + nextAction: "Prepare updated commercial proposal", + createdBy: assignedUser.userId, + updatedBy: assignedUser.userId, + }); + opportunityFollowups.push({ + id: crypto.randomUUID(), + opportunityId: opportunity.id, + contactId: contact?.id ?? null, + followupDate: + story.slug === "followup-overdue" + ? daysAgo(2, 16) + : addDays(opportunityCreatedAt, 12), + followupTypeId: getOptionId( + optionMap, + "crm_followup_type", + story.slug === "followup-overdue" ? "call" : "email", + ), + outcome: + story.slug === "followup-overdue" + ? "Customer asked for updated date but salesperson missed next promised response." + : "Commercial follow-up sent and next action agreed.", + notes: + story.slug === "followup-overdue" + ? "Deliberately overdue for dashboard testing." + : "Discussed approval path, commercial gaps, and expected close timing.", + nextFollowupDate: + story.slug === "followup-overdue" + ? daysAgo(1) + : story.opportunityOutcome === "open" + ? daysAhead(2) + : null, + nextAction: + story.slug === "followup-overdue" + ? "Call customer and re-confirm revised survey date" + : story.opportunityOutcome === "open" + ? "Push approval and customer sign-off" + : null, + createdBy: assignedUser.userId, + updatedBy: assignedUser.userId, + }); + + for (const party of story.multiParties ?? []) { + const relatedCustomer = allCustomers.find( + (item) => item.name === party.customerName, + ); + if (!relatedCustomer) continue; + opportunityParties.push({ + id: crypto.randomUUID(), + opportunityId: opportunity.id, + customerId: relatedCustomer.id, + roleId: getOptionId( + optionMap, + "crm_project_party_role", + party.roleCode, + ), + remark: `${party.roleCode.replaceAll("_", " ")} for ${story.projectName}`, + }); + } + } + + const extraLeadStatuses = [ + "new_job", + "new_job", + "follow_up", + "follow_up", + "assigned", + "assigned", + "assigned", + "closed_lost", + "cancel", + ] as const; + const extraFollowupStatuses = [ + "budget_pending", + "project_under_review", + "spec_clarification", + "site_survey_waiting", + "drawing_followup", + "quotation_preparation", + ] as const; + const extraOpportunityStages = [ + "new", + "qualification", + "requirement_gathering", + "site_survey", + "proposal_preparation", + "quotation_created", + "negotiation", + ] as const; + + while (leads.length < 80) { + const customer = allCustomers[leads.length % allCustomers.length]; + const contact = primaryContactByCustomerId.get(customer.id) ?? null; + const statusCode = choose(extraLeadStatuses, leads.length); + const createdAt = daysAgo(75 - (leads.length % 45)); + const salesOwner = choose(users.sales, leads.length); + const lead = { + id: crypto.randomUUID(), + code: buildDocumentCode("LD", leadSequence++), + customerId: customer.id, + contactId: contact?.id ?? null, + branchId: customer.branchId, + description: `General UAT lead for ${customer.name}. ${seededNote(`lead-${leadSequence}`)}`, + projectName: `${choose(["Warehouse Expansion", "Factory Modernization", "Maintenance Upgrade", "Loading Bay Improvement"], leads.length)} ${Math.floor(leads.length / 4) + 1}`, + projectLocation: `${customer.district}, ${customer.province}`, + productTypeId: getOptionId( + optionMap, + "crm_product_type", + choose(["crane", "dockdoor", "solarcell"], leads.length), + ), + priorityId: getOptionId( + optionMap, + "crm_priority", + choose(["low", "normal", "high"], leads.length), + ), + estimatedValue: 850000 + (leads.length % 9) * 420000, + awarenessId: getOptionalOptionId( + optionMap, + "crm_lead_awareness", + choose( + ["social_media", "visit", "brochure_catalog", "repurchasing"], + leads.length, + ), + ), + statusId: getOptionId(optionMap, "crm_lead_status", statusCode), + followupStatusId: getOptionalOptionId( + optionMap, + "crm_lead_followup_status", + choose(extraFollowupStatuses, leads.length), + ), + lostReasonId: + statusCode === "closed_lost" + ? getOptionalOptionId(optionMap, "crm_lead_lost_reason", "price") + : null, + outcome: statusCode === "closed_lost" ? "lost" : "open", + ownerMarketingUserId: users.marketing.userId, + assignedSalesOwnerId: + statusCode === "assigned" || statusCode === "closed_lost" + ? salesOwner.userId + : null, + assignedAt: + statusCode === "assigned" || statusCode === "closed_lost" + ? addDays(createdAt, 3) + : null, + assignedBy: + statusCode === "assigned" || statusCode === "closed_lost" + ? users.marketing.userId + : null, + assignmentRemark: + statusCode === "assigned" || statusCode === "closed_lost" + ? "Qualified and handed over for sales action." + : null, + createdBy: users.marketing.userId, + createdAt, + } satisfies SeedLead; + leads.push(lead); + leadFollowups.push({ + id: crypto.randomUUID(), + leadId: lead.id, + branchId: lead.branchId, + userId: users.marketing.userId, + followupDate: addDays(createdAt, 2), + followupStatusId: + lead.followupStatusId ?? + getOptionId(optionMap, "crm_lead_followup_status", "budget_pending"), + note: "Seeded follow-up for aging and dashboard validation.", + nextFollowupDate: statusCode === "new_job" ? daysAhead(1) : null, + createdAt: addDays(createdAt, 2, 15), + }); + } + + while (opportunities.length < 50) { + const customer = allCustomers[opportunities.length % allCustomers.length]; + const contact = primaryContactByCustomerId.get(customer.id) ?? null; + const createdAt = daysAgo(68 - (opportunities.length % 32)); + const salesOwner = choose(users.sales, opportunities.length); + const outcomeMode = choose( + ["open", "won", "lost", "cancelled", "no_quotation"], + opportunities.length, + ); + const pipelineStage = + outcomeMode === "won" + ? "closed_won" + : outcomeMode === "lost" + ? "closed_lost" + : opportunities.length % 4 === 0 + ? "lead" + : "opportunity"; + const closedAt = + pipelineStage === "closed_won" || pipelineStage === "closed_lost" + ? addDays(createdAt, 18) + : null; + opportunities.push({ + id: crypto.randomUUID(), + code: buildDocumentCode("EN", opportunitySequence++), + customerId: customer.id, + contactId: contact?.id ?? null, + leadId: null, + branchId: customer.branchId, + title: `${choose(["Retrofit Program", "Capacity Expansion", "Safety Improvement", "Site Package"], opportunities.length)} ${opportunities.length + 1}`, + description: + "Supplemental seeded opportunity for dashboards and reports.", + requirement: + "Customer requested commercial proposal and implementation plan.", + projectName: `${customer.name} - ${choose(["North Plant", "Central Warehouse", "Phase 2", "Service Contract"], opportunities.length)}`, + projectLocation: `${customer.district}, ${customer.province}`, + productTypeId: getOptionId( + optionMap, + "crm_product_type", + choose(["crane", "dockdoor", "solarcell"], opportunities.length), + ), + statusId: getOptionId( + optionMap, + "crm_opportunity_stage", + choose(extraOpportunityStages, opportunities.length), + ), + outcomeStatus: outcomeMode as SeedOpportunity["outcomeStatus"], + priorityId: getOptionId( + optionMap, + "crm_priority", + choose(["normal", "high", "urgent"], opportunities.length), + ), + leadChannelId: getOptionalOptionId( + optionMap, + "crm_lead_channel", + choose( + ["website", "sales", "referral", "facebook"], + opportunities.length, + ), + ), + estimatedValue: 1100000 + (opportunities.length % 10) * 540000, + chancePercent: + pipelineStage === "lead" ? 25 : 45 + (opportunities.length % 4) * 10, + expectedCloseDate: closedAt ?? addDays(createdAt, 21), + competitor: + outcomeMode === "lost" + ? choose(["Konecranes", "Demag", "Cibes"], opportunities.length) + : null, + source: `${SEED_TAG}:supplemental-${opportunities.length + 1}`, + notes: `${seededNote(`opportunity-${opportunities.length + 1}`)} Supplemental UAT opportunity.`, + isHotProject: opportunities.length % 7 === 0, + isActive: true, + pipelineStage, + closedAt, + closedWonAt: pipelineStage === "closed_won" ? closedAt : null, + closedLostAt: pipelineStage === "closed_lost" ? closedAt : null, + closedByUserId: closedAt ? users.salesManager.userId : null, + poNumber: + pipelineStage === "closed_won" + ? `PO-${String(2606200 + opportunities.length).padStart(7, "0")}` + : null, + poDate: pipelineStage === "closed_won" ? addDays(closedAt!, -1) : null, + poAmount: + pipelineStage === "closed_won" + ? 1250000 + (opportunities.length % 6) * 320000 + : null, + poCurrencyId: + pipelineStage === "closed_won" + ? getOptionId(optionMap, "crm_currency", "THB") + : null, + lostReasonId: + pipelineStage === "closed_lost" + ? getOptionalOptionId( + optionMap, + "crm_opportunity_lost_reason", + "competitor_won", + ) + : null, + lostDetail: + pipelineStage === "closed_lost" + ? "Competitor secured lower commercial package." + : null, + lostCompetitor: + pipelineStage === "closed_lost" + ? choose(["Konecranes", "Demag"], opportunities.length) + : null, + lostRemark: + pipelineStage === "closed_lost" + ? "Closed during final commercial comparison." + : null, + cancelReasonId: + outcomeMode === "cancelled" + ? getOptionalOptionId( + optionMap, + "crm_opportunity_cancel_reason", + "scope_changed", + ) + : null, + noQuotationReasonId: + outcomeMode === "no_quotation" + ? getOptionalOptionId( + optionMap, + "crm_opportunity_no_quotation_reason", + "customer_stopped", + ) + : null, + assignedToUserId: pipelineStage === "lead" ? null : salesOwner.userId, + assignedAt: pipelineStage === "lead" ? null : addDays(createdAt, 1), + assignedBy: pipelineStage === "lead" ? null : users.salesManager.userId, + assignmentRemark: + pipelineStage === "lead" + ? null + : "Seeded assignment for dashboard distribution.", + createdBy: salesOwner.userId, + updatedBy: salesOwner.userId, + createdAt, + updatedAt: closedAt ?? addDays(createdAt, 9), + }); + opportunityFollowups.push({ + id: crypto.randomUUID(), + opportunityId: opportunities[opportunities.length - 1]!.id, + contactId: contact?.id ?? null, + followupDate: addDays(createdAt, 4), + followupTypeId: getOptionId( + optionMap, + "crm_followup_type", + choose(["call", "meeting", "email"], opportunities.length), + ), + outcome: "Supplemental follow-up seeded for pipeline analytics.", + notes: "Used for due and overdue follow-up widgets.", + nextFollowupDate: + outcomeMode === "open" + ? daysAhead((opportunities.length % 4) - 1) + : null, + nextAction: + outcomeMode === "open" ? "Confirm next commercial action" : null, + createdBy: salesOwner.userId, + updatedBy: salesOwner.userId, + }); + } + + return { + leads, + leadFollowups, + opportunities, + opportunityParties, + opportunityFollowups, + storyOpportunityMap, + }; +} + +function buildQuotationData( + optionMap: Map, + storyOpportunityMap: Map, + storyCustomerMap: Map, + allCustomers: SeedCustomer[], + primaryContactByCustomerId: Map, + users: UserContext, +) { + const quotations: SeedQuotation[] = []; + const quotationItems: SeedQuotationItem[] = []; + const quotationParties: SeedQuotationParty[] = []; + const quotationTopics: SeedQuotationTopic[] = []; + const quotationFollowups: SeedQuotationFollowup[] = []; + const approvalRequests: SeedApprovalRequest[] = []; + const approvalActions: SeedApprovalAction[] = []; + + let quotationSequence = 1101; + + const acceptedStories = new Set([ + "website-warehouse-crane-win", + "high-value-hot-project", + "line-maintenance-renewal", + ]); + + for (const [storyIndex, story] of STORIES.entries()) { + const opportunity = storyOpportunityMap.get(story.slug); + const customer = storyCustomerMap.get(story.slug); + if (!opportunity || !customer) continue; + const contact = primaryContactByCustomerId.get(customer.id) ?? null; + const salesperson = choose(users.sales, storyIndex); + const baseDate = addDays(opportunity.createdAt, 10); + const planRevisionCount = + story.quotePlan === "returned_then_revised" + ? 4 + : story.quotePlan === "rejected_then_revised" + ? 3 + : acceptedStories.has(story.slug) + ? 2 + : 1; + + let familyRootId: string | null = null; + for (let revision = 0; revision < planRevisionCount; revision += 1) { + const quotationId = crypto.randomUUID(); + if (!familyRootId) familyRootId = quotationId; + + const statusCode = + story.quotePlan === "pending_step_1" + ? "pending_approval" + : story.quotePlan === "pending_step_2" + ? revision === planRevisionCount - 1 + ? "pending_approval" + : "revised" + : story.quotePlan === "approved" + ? revision === planRevisionCount - 1 + ? "approved" + : "revised" + : story.quotePlan === "accepted" + ? revision === planRevisionCount - 1 + ? "accepted" + : "revised" + : story.quotePlan === "rejected_then_revised" + ? revision === planRevisionCount - 1 + ? "accepted" + : revision === planRevisionCount - 2 + ? "rejected" + : "revised" + : story.quotePlan === "returned_then_revised" + ? revision === planRevisionCount - 1 + ? "approved" + : revision === planRevisionCount - 2 + ? "draft" + : "revised" + : story.quotePlan === "sent_follow_up" + ? revision === planRevisionCount - 1 + ? "sent" + : "revised" + : "cancelled"; + + const quotationDate = addDays(baseDate, revision * 6); + const subtotal = Math.round( + (story.estimatedValue / Math.max(planRevisionCount, 1)) * 0.94, + ); + const discount = + revision === planRevisionCount - 1 + ? Math.round(subtotal * 0.03) + : Math.round(subtotal * 0.01); + const taxRate = 7; + const taxAmount = Math.round((subtotal - discount) * (taxRate / 100)); + const totalAmount = subtotal - discount + taxAmount; + + quotations.push({ + id: quotationId, + code: buildDocumentCode("QT", quotationSequence++), + opportunityId: opportunity.id, + customerId: customer.id, + contactId: contact?.id ?? null, + branchId: customer.branchId, + quotationDate, + validUntil: addDays(quotationDate, 30), + quotationTypeId: getOptionId( + optionMap, + "crm_quotation_type", + story.quotationTypeCode, + ), + projectName: story.projectName, + projectLocation: story.projectLocation, + attention: contact?.name ?? null, + reference: `${SEED_TAG}:${story.slug}`, + notes: `${story.title} / revision ${revision}`, + statusId: getOptionId(optionMap, "crm_quotation_status", statusCode), + revision, + parentQuotationId: revision === 0 ? null : familyRootId, + revisionRemark: + revision === 0 + ? null + : `Revision R${String(revision).padStart(2, "0")} for ${story.slug}`, + currencyId: getOptionId(optionMap, "crm_currency", "THB"), + exchangeRate: 1, + subtotal, + discount, + discountTypeId: getOptionalOptionId( + optionMap, + "crm_discount_type", + "fixed", + ), + taxRate, + taxAmount, + totalAmount, + chancePercent: story.chancePercent, + isHotProject: story.estimatedValue > 10000000, + competitor: story.competitor ?? null, + salesmanId: salesperson.userId, + isSent: ["sent", "approved", "accepted", "rejected"].includes( + statusCode, + ), + sentAt: ["sent", "approved", "accepted", "rejected"].includes( + statusCode, + ) + ? addDays(quotationDate, 2, 14) + : null, + sentViaId: ["sent", "approved", "accepted", "rejected"].includes( + statusCode, + ) + ? (getOptionalOptionId( + optionMap, + "crm_sent_via", + revision % 2 === 0 ? "email" : "line", + ) ?? getOptionId(optionMap, "crm_sent_via", "email")) + : null, + approvedAt: ["approved", "accepted"].includes(statusCode) + ? addDays(quotationDate, 3, 15) + : null, + acceptedAt: + statusCode === "accepted" ? addDays(quotationDate, 7, 10) : null, + rejectedAt: + statusCode === "rejected" ? addDays(quotationDate, 5, 11) : null, + rejectionReason: + statusCode === "rejected" + ? "Customer requested layout revision and commercial warranty adjustment." + : null, + createdBy: salesperson.userId, + updatedBy: salesperson.userId, + createdAt: quotationDate, + updatedAt: addDays(quotationDate, 5), + }); + + const itemTemplates = [ + ["Main equipment supply", 1, Math.round(totalAmount * 0.55)], + ["Installation and commissioning", 1, Math.round(totalAmount * 0.28)], + [ + "Operator training and documentation", + 1, + Math.round(totalAmount * 0.1), + ], + ["Project management and testing", 1, Math.round(totalAmount * 0.07)], + ] as const; + + itemTemplates.forEach(([description, quantity, unitPrice], itemIndex) => { + quotationItems.push({ + id: crypto.randomUUID(), + quotationId, + itemNumber: itemIndex + 1, + productTypeId: getOptionId( + optionMap, + "crm_product_type", + story.productTypeCode, + ), + description: `${description} for ${story.projectName}`, + quantity, + unitId: getOptionalOptionId( + optionMap, + "crm_unit", + itemIndex === 0 ? "set" : "job", + ), + unitPrice, + discount: 0, + discountTypeId: null, + taxRate, + totalPrice: unitPrice, + notes: itemIndex === 0 ? "Seeded major commercial line item." : null, + sortOrder: itemIndex + 1, + }); + }); + + quotationParties.push({ + id: crypto.randomUUID(), + quotationId, + customerId: customer.id, + roleId: getOptionId(optionMap, "crm_project_party_role", "customer"), + isPrimary: true, + remark: "Primary customer", + }); + + for (const party of story.multiParties ?? []) { + const relatedCustomer = allCustomers.find( + (item) => item.name === party.customerName, + ); + if (!relatedCustomer) continue; + quotationParties.push({ + id: crypto.randomUUID(), + quotationId, + customerId: relatedCustomer.id, + roleId: getOptionId( + optionMap, + "crm_project_party_role", + party.roleCode, + ), + isPrimary: false, + remark: `${party.roleCode.replaceAll("_", " ")} relation`, + }); + } + + const topicTypeCodes = ["scope", "exclusion", "payment"] as const; + topicTypeCodes.forEach((topicCode, topicIndex) => { + const title = + topicCode === "scope" + ? "Scope of Work" + : topicCode === "exclusion" + ? "Exclusions" + : "Payment Terms"; + quotationTopics.push({ + id: crypto.randomUUID(), + quotationId, + topicTypeId: getOptionId( + optionMap, + "crm_quotation_topic_type", + topicCode, + ), + title, + sortOrder: topicIndex + 1, + items: [ + { + id: crypto.randomUUID(), + content: + topicCode === "scope" + ? `Supply and execute ${story.projectName.toLowerCase()} according to approved drawing.` + : topicCode === "exclusion" + ? "Civil work, main power cable, and customer standby crew are excluded." + : "40% deposit, 50% after delivery, 10% after commissioning.", + sortOrder: 1, + }, + { + id: crypto.randomUUID(), + content: + topicCode === "scope" + ? "Include testing, commissioning, and handover documentation." + : topicCode === "exclusion" + ? "Work outside normal business hours is not included." + : "Price validity 30 days and delivery 60-90 days after order confirmation.", + sortOrder: 2, + }, + ], + }); + }); + + quotationFollowups.push({ + id: crypto.randomUUID(), + quotationId, + contactId: contact?.id ?? null, + followupDate: addDays(quotationDate, 4), + followupTypeId: getOptionId(optionMap, "crm_followup_type", "email"), + outcome: + statusCode === "pending_approval" + ? "Waiting internal approval routing." + : "Quotation delivered and customer acknowledged receipt.", + notes: "Seeded quotation follow-up for dashboard and detail tabs.", + nextFollowupDate: + statusCode === "sent" + ? daysAgo(story.slug === "followup-overdue" ? 1 : -2) + : statusCode === "pending_approval" + ? daysAhead(1) + : null, + nextAction: + statusCode === "pending_approval" + ? "Remind internal approver" + : statusCode === "sent" + ? "Call customer for quotation review" + : null, + createdBy: salesperson.userId, + updatedBy: salesperson.userId, + }); + + if ( + revision === planRevisionCount - 1 && + [ + "pending_step_1", + "pending_step_2", + "approved", + "accepted", + "rejected_then_revised", + "returned_then_revised", + ].includes(story.quotePlan) + ) { + const requestId = crypto.randomUUID(); + const requestedAt = addDays(quotationDate, 1, 10); + const currentStep = + story.quotePlan === "pending_step_2" + ? 2 + : story.quotePlan === "pending_step_1" + ? 1 + : story.quotePlan === "returned_then_revised" + ? 2 + : 3; + const requestStatus = + story.quotePlan === "pending_step_1" || + story.quotePlan === "pending_step_2" + ? "pending" + : story.quotePlan === "returned_then_revised" + ? "returned" + : story.quotePlan === "rejected_then_revised" + ? "approved" + : "approved"; + approvalRequests.push({ + id: requestId, + workflowId: "", + entityId: quotationId, + currentStep, + status: requestStatus, + requestedBy: salesperson.userId, + requestedAt, + completedAt: + requestStatus === "pending" ? null : addDays(requestedAt, 3, 17), + createdAt: requestedAt, + updatedAt: + requestStatus === "pending" + ? addDays(requestedAt, currentStep - 1, 16) + : addDays(requestedAt, 3, 17), + }); + approvalActions.push({ + id: crypto.randomUUID(), + approvalRequestId: requestId, + stepNumber: 1, + action: "submit", + remark: "Submitted for commercial approval", + actedBy: salesperson.userId, + actedAt: requestedAt, + }); + if (story.quotePlan !== "pending_step_1") { + approvalActions.push({ + id: crypto.randomUUID(), + approvalRequestId: requestId, + stepNumber: 1, + action: "approve", + remark: "Commercial margin acceptable", + actedBy: users.salesManager.userId, + actedAt: addDays(requestedAt, 1, 11), + }); + } + if (story.quotePlan === "pending_step_2") { + continue; + } + if (story.quotePlan === "returned_then_revised") { + approvalActions.push({ + id: crypto.randomUUID(), + approvalRequestId: requestId, + stepNumber: 2, + action: "return", + remark: "Please revise payment term and warranty wording.", + actedBy: users.departmentManager.userId, + actedAt: addDays(requestedAt, 2, 14), + }); + continue; + } + approvalActions.push({ + id: crypto.randomUUID(), + approvalRequestId: requestId, + stepNumber: 2, + action: "approve", + remark: "Department approved commercial assumptions.", + actedBy: users.departmentManager.userId, + actedAt: addDays(requestedAt, 2, 10), + }); + approvalActions.push({ + id: crypto.randomUUID(), + approvalRequestId: requestId, + stepNumber: 3, + action: "approve", + remark: "Final approval completed.", + actedBy: users.topManager.userId, + actedAt: addDays(requestedAt, 3, 9), + }); + } + } + } + + while (quotations.length < 60) { + const customer = allCustomers[quotations.length % allCustomers.length]; + const contact = primaryContactByCustomerId.get(customer.id) ?? null; + const salesperson = choose(users.sales, quotations.length); + const quotationDate = daysAgo(55 - (quotations.length % 28)); + const statusCode = choose( + [ + "draft", + "pending_approval", + "approved", + "sent", + "accepted", + "lost", + "cancelled", + ], + quotations.length, + ); + const subtotal = 980000 + (quotations.length % 8) * 240000; + const discount = quotations.length % 3 === 0 ? 25000 : 0; + const taxRate = 7; + const taxAmount = Math.round((subtotal - discount) * 0.07); + const totalAmount = subtotal - discount + taxAmount; + const quotationId = crypto.randomUUID(); + quotations.push({ + id: quotationId, + code: buildDocumentCode("QT", quotationSequence++), + opportunityId: null, + customerId: customer.id, + contactId: contact?.id ?? null, + branchId: customer.branchId, + quotationDate, + validUntil: addDays(quotationDate, 30), + quotationTypeId: getOptionId( + optionMap, + "crm_quotation_type", + choose(["crane", "dockdoor", "solarcell"], quotations.length), + ), + projectName: `${customer.name} Supplemental Quotation ${quotations.length + 1}`, + projectLocation: `${customer.district}, ${customer.province}`, + attention: contact?.name ?? null, + reference: `${SEED_TAG}:supplemental-${quotations.length}`, + notes: "Supplemental UAT quotation for status distribution.", + statusId: getOptionId(optionMap, "crm_quotation_status", statusCode), + revision: 0, + parentQuotationId: null, + revisionRemark: null, + currencyId: getOptionId(optionMap, "crm_currency", "THB"), + exchangeRate: 1, + subtotal, + discount, + discountTypeId: + discount > 0 + ? getOptionalOptionId(optionMap, "crm_discount_type", "fixed") + : null, + taxRate, + taxAmount, + totalAmount, + chancePercent: 25 + (quotations.length % 5) * 10, + isHotProject: quotations.length % 9 === 0, + competitor: + statusCode === "lost" + ? choose(["Konecranes", "Demag"], quotations.length) + : null, + salesmanId: salesperson.userId, + isSent: [ + "pending_approval", + "approved", + "sent", + "accepted", + "lost", + ].includes(statusCode), + sentAt: [ + "pending_approval", + "approved", + "sent", + "accepted", + "lost", + ].includes(statusCode) + ? addDays(quotationDate, 2, 13) + : null, + sentViaId: [ + "pending_approval", + "approved", + "sent", + "accepted", + "lost", + ].includes(statusCode) + ? (getOptionalOptionId( + optionMap, + "crm_sent_via", + choose(["email", "manual"], quotations.length), + ) ?? getOptionId(optionMap, "crm_sent_via", "email")) + : null, + approvedAt: ["approved", "accepted"].includes(statusCode) + ? addDays(quotationDate, 3, 15) + : null, + acceptedAt: + statusCode === "accepted" ? addDays(quotationDate, 6, 10) : null, + rejectedAt: statusCode === "lost" ? addDays(quotationDate, 8, 11) : null, + rejectionReason: + statusCode === "lost" + ? "Customer selected another vendor after commercial comparison." + : null, + createdBy: salesperson.userId, + updatedBy: salesperson.userId, + createdAt: quotationDate, + updatedAt: addDays(quotationDate, 7), + }); + quotationItems.push({ + id: crypto.randomUUID(), + quotationId, + itemNumber: 1, + productTypeId: getOptionId( + optionMap, + "crm_product_type", + choose(["crane", "dockdoor", "solarcell"], quotations.length), + ), + description: "Supplemental scope item", + quantity: 1, + unitId: getOptionalOptionId(optionMap, "crm_unit", "job"), + unitPrice: totalAmount, + discount: 0, + discountTypeId: null, + taxRate, + totalPrice: totalAmount, + notes: null, + sortOrder: 1, + }); + quotationParties.push({ + id: crypto.randomUUID(), + quotationId, + customerId: customer.id, + roleId: getOptionId(optionMap, "crm_project_party_role", "customer"), + isPrimary: true, + remark: null, + }); + quotationFollowups.push({ + id: crypto.randomUUID(), + quotationId, + contactId: contact?.id ?? null, + followupDate: addDays(quotationDate, 4), + followupTypeId: getOptionId( + optionMap, + "crm_followup_type", + choose(["email", "call"], quotations.length), + ), + outcome: "Supplemental quotation follow-up", + notes: "Used for dashboard follow-up distribution.", + nextFollowupDate: + statusCode === "sent" ? daysAhead((quotations.length % 3) - 1) : null, + nextAction: statusCode === "sent" ? "Customer review call" : null, + createdBy: salesperson.userId, + updatedBy: salesperson.userId, + }); + } + + return { + quotations, + quotationItems, + quotationParties, + quotationTopics, + quotationFollowups, + approvalRequests, + approvalActions, + }; +} + +async function insertSeedData( + sql: SqlClient, + organization: OrganizationRow, + workflowId: string, + customers: SeedCustomer[], + contacts: SeedContact[], + leads: SeedLead[], + leadFollowups: SeedLeadFollowup[], + opportunities: SeedOpportunity[], + opportunityParties: SeedOpportunityParty[], + opportunityFollowups: SeedOpportunityFollowup[], + quotations: SeedQuotation[], + quotationItems: SeedQuotationItem[], + quotationParties: SeedQuotationParty[], + quotationTopics: SeedQuotationTopic[], + quotationFollowups: SeedQuotationFollowup[], + approvalRequests: SeedApprovalRequest[], + approvalActions: SeedApprovalAction[], +) { + for (const customer of customers) { + await sql` + insert into crm_customers ( + id, organization_id, branch_id, code, name, tax_id, customer_type, customer_status, + address, province, district, country, phone, email, website, lead_channel, + customer_group, customer_sub_group, owner_user_id, owner_assigned_at, owner_assigned_by, + notes, is_active, created_at, updated_at, created_by, updated_by + ) values ( + ${customer.id}, ${organization.id}, ${customer.branchId}, ${customer.code}, ${customer.name}, null, ${customer.customerTypeId}, + ${customer.customerStatusId}, ${`${customer.district}, ${customer.province}`}, ${customer.province}, ${customer.district}, + ${"Thailand"}, ${customer.phone}, ${customer.email}, ${customer.website}, ${customer.leadChannelId}, + ${customer.customerGroupId}, ${customer.customerSubGroupId}, ${customer.ownerUserId}, ${daysAgo(89)}, + ${organization.createdBy}, ${customer.notes}, ${true}, ${daysAgo(89)}, ${daysAgo(1)}, ${customer.createdBy}, ${customer.createdBy} + ) + `; + await sql` + insert into crm_customer_owner_history ( + id, organization_id, customer_id, old_owner_user_id, new_owner_user_id, changed_by, changed_at, remark + ) values ( + ${crypto.randomUUID()}, ${organization.id}, ${customer.id}, null, ${customer.ownerUserId}, ${customer.createdBy}, + ${daysAgo(89)}, ${"Initial seeded customer owner"} + ) + `; + } + + for (const contact of contacts) { + await sql` + insert into crm_customer_contacts ( + id, organization_id, customer_id, name, position, department, phone, mobile, email, + is_primary, notes, is_active, created_at, updated_at, created_by, updated_by + ) values ( + ${contact.id}, ${organization.id}, ${contact.customerId}, ${contact.name}, ${contact.position}, ${contact.department}, + ${contact.phone}, ${contact.mobile}, ${contact.email}, ${contact.isPrimary}, ${contact.notes}, ${true}, + ${daysAgo(85)}, ${daysAgo(2)}, ${contact.createdBy}, ${contact.createdBy} + ) + `; + } + + for (const lead of leads) { + await sql` + insert into crm_leads ( + id, organization_id, branch_id, code, customer_id, contact_id, description, project_name, project_location, + product_type, priority, estimated_value, awareness_id, status, followup_status, lost_reason, outcome, + owner_marketing_user_id, assigned_sales_owner_id, assigned_at, assigned_by, assignment_remark, + created_by, created_at, updated_at + ) values ( + ${lead.id}, ${organization.id}, ${lead.branchId}, ${lead.code}, ${lead.customerId}, ${lead.contactId}, + ${lead.description}, ${lead.projectName}, ${lead.projectLocation}, ${lead.productTypeId}, ${lead.priorityId}, + ${lead.estimatedValue}, ${lead.awarenessId}, ${lead.statusId}, ${lead.followupStatusId}, ${lead.lostReasonId}, + ${lead.outcome}, ${lead.ownerMarketingUserId}, ${lead.assignedSalesOwnerId}, ${lead.assignedAt}, + ${lead.assignedBy}, ${lead.assignmentRemark}, ${lead.createdBy}, ${lead.createdAt}, ${lead.createdAt} + ) + `; + } + + for (const followup of leadFollowups) { + const payload = { + id: followup.id, + leadId: followup.leadId, + followupDate: followup.followupDate.toISOString(), + followupStatus: followup.followupStatusId, + note: followup.note, + nextFollowupDate: followup.nextFollowupDate?.toISOString() ?? null, + createdBy: followup.userId, + createdAt: followup.createdAt.toISOString(), + }; + await sql` + insert into tr_audit_logs ( + id, organization_id, branch_id, user_id, entity_type, entity_id, action, after_data, created_at + ) values ( + ${crypto.randomUUID()}, ${organization.id}, ${followup.branchId}, ${followup.userId}, + ${"crm_lead"}, ${followup.leadId}, ${"create_lead_followup"}, ${sql.json(payload)}, ${followup.createdAt} + ) + `; + } + + for (const opportunity of opportunities) { + await sql` + insert into crm_opportunities ( + id, organization_id, branch_id, lead_id, code, customer_id, contact_id, title, description, requirement, + project_name, project_location, product_type, status, outcome_status, priority, lead_channel, estimated_value, + chance_percent, expected_close_date, competitor, source, notes, is_hot_project, is_active, pipeline_stage, + closed_at, closed_won_at, closed_lost_at, closed_by_user_id, po_number, po_date, po_amount, po_currency, + lost_reason, lost_detail, lost_competitor, lost_remark, cancel_reason, no_quotation_reason, + assigned_to_user_id, assigned_at, assigned_by, assignment_remark, created_by, updated_by, created_at, updated_at + ) values ( + ${opportunity.id}, ${organization.id}, ${opportunity.branchId}, ${opportunity.leadId}, ${opportunity.code}, + ${opportunity.customerId}, ${opportunity.contactId}, ${opportunity.title}, ${opportunity.description}, + ${opportunity.requirement}, ${opportunity.projectName}, ${opportunity.projectLocation}, ${opportunity.productTypeId}, + ${opportunity.statusId}, ${opportunity.outcomeStatus}, ${opportunity.priorityId}, ${opportunity.leadChannelId}, + ${opportunity.estimatedValue}, ${opportunity.chancePercent}, ${opportunity.expectedCloseDate}, + ${opportunity.competitor}, ${opportunity.source}, ${opportunity.notes}, ${opportunity.isHotProject}, + ${opportunity.isActive}, ${opportunity.pipelineStage}, ${opportunity.closedAt}, ${opportunity.closedWonAt}, + ${opportunity.closedLostAt}, ${opportunity.closedByUserId}, ${opportunity.poNumber}, ${opportunity.poDate}, + ${opportunity.poAmount}, ${opportunity.poCurrencyId}, ${opportunity.lostReasonId}, ${opportunity.lostDetail}, + ${opportunity.lostCompetitor}, ${opportunity.lostRemark}, ${opportunity.cancelReasonId}, + ${opportunity.noQuotationReasonId}, ${opportunity.assignedToUserId}, ${opportunity.assignedAt}, + ${opportunity.assignedBy}, ${opportunity.assignmentRemark}, ${opportunity.createdBy}, ${opportunity.updatedBy}, + ${opportunity.createdAt}, ${opportunity.updatedAt} + ) + `; + } + + for (const party of opportunityParties) { + await sql` + insert into crm_opportunity_customers ( + id, organization_id, opportunity_id, customer_id, role, remark, created_at, updated_at + ) values ( + ${party.id}, ${organization.id}, ${party.opportunityId}, ${party.customerId}, ${party.roleId}, ${party.remark}, + ${daysAgo(50)}, ${daysAgo(3)} + ) + `; + } + + for (const followup of opportunityFollowups) { + await sql` + insert into crm_opportunity_followups ( + id, organization_id, opportunity_id, followup_date, followup_type, contact_id, outcome, notes, + next_followup_date, next_action, created_at, updated_at, created_by, updated_by + ) values ( + ${followup.id}, ${organization.id}, ${followup.opportunityId}, ${followup.followupDate}, ${followup.followupTypeId}, + ${followup.contactId}, ${followup.outcome}, ${followup.notes}, ${followup.nextFollowupDate}, + ${followup.nextAction}, ${followup.followupDate}, ${followup.followupDate}, ${followup.createdBy}, ${followup.updatedBy} + ) + `; + } + + for (const quotation of quotations) { + await sql` + insert into crm_quotations ( + id, organization_id, branch_id, code, opportunity_id, customer_id, contact_id, quotation_date, valid_until, + quotation_type, project_name, project_location, attention, reference, notes, status, revision, parent_quotation_id, + revision_remark, currency, exchange_rate, subtotal, discount, discount_type, tax_rate, tax_amount, total_amount, + chance_percent, is_hot_project, competitor, salesman_id, is_sent, sent_at, sent_via, approved_at, accepted_at, + rejected_at, rejection_reason, is_active, created_at, updated_at, created_by, updated_by + ) values ( + ${quotation.id}, ${organization.id}, ${quotation.branchId}, ${quotation.code}, ${quotation.opportunityId}, + ${quotation.customerId}, ${quotation.contactId}, ${quotation.quotationDate}, ${quotation.validUntil}, + ${quotation.quotationTypeId}, ${quotation.projectName}, ${quotation.projectLocation}, ${quotation.attention}, + ${quotation.reference}, ${quotation.notes}, ${quotation.statusId}, ${quotation.revision}, + ${quotation.parentQuotationId}, ${quotation.revisionRemark}, ${quotation.currencyId}, ${quotation.exchangeRate}, + ${quotation.subtotal}, ${quotation.discount}, ${quotation.discountTypeId}, ${quotation.taxRate}, + ${quotation.taxAmount}, ${quotation.totalAmount}, ${quotation.chancePercent}, ${quotation.isHotProject}, + ${quotation.competitor}, ${quotation.salesmanId}, ${quotation.isSent}, ${quotation.sentAt}, ${quotation.sentViaId}, + ${quotation.approvedAt}, ${quotation.acceptedAt}, ${quotation.rejectedAt}, ${quotation.rejectionReason}, + ${true}, ${quotation.createdAt}, ${quotation.updatedAt}, ${quotation.createdBy}, ${quotation.updatedBy} + ) + `; + } + + for (const item of quotationItems) { + await sql` + insert into crm_quotation_items ( + id, organization_id, quotation_id, item_number, product_type, description, quantity, unit, unit_price, + discount, discount_type, tax_rate, total_price, notes, sort_order, created_at, updated_at + ) values ( + ${item.id}, ${organization.id}, ${item.quotationId}, ${item.itemNumber}, ${item.productTypeId}, + ${item.description}, ${item.quantity}, ${item.unitId}, ${item.unitPrice}, ${item.discount}, + ${item.discountTypeId}, ${item.taxRate}, ${item.totalPrice}, ${item.notes}, ${item.sortOrder}, + ${daysAgo(40)}, ${daysAgo(2)} + ) + `; + } + + for (const party of quotationParties) { + await sql` + insert into crm_quotation_customers ( + id, organization_id, quotation_id, customer_id, role, is_primary, remark, created_at, updated_at + ) values ( + ${party.id}, ${organization.id}, ${party.quotationId}, ${party.customerId}, ${party.roleId}, ${party.isPrimary}, + ${party.remark}, ${daysAgo(40)}, ${daysAgo(2)} + ) + `; + } + + for (const topic of quotationTopics) { + await sql` + insert into crm_quotation_topics ( + id, organization_id, quotation_id, topic_type, title, sort_order, created_at, updated_at + ) values ( + ${topic.id}, ${organization.id}, ${topic.quotationId}, ${topic.topicTypeId}, ${topic.title}, + ${topic.sortOrder}, ${daysAgo(35)}, ${daysAgo(3)} + ) + `; + for (const topicItem of topic.items) { + await sql` + insert into crm_quotation_topic_items ( + id, organization_id, topic_id, content, sort_order, created_at, updated_at + ) values ( + ${topicItem.id}, ${organization.id}, ${topic.id}, ${topicItem.content}, ${topicItem.sortOrder}, + ${daysAgo(35)}, ${daysAgo(3)} + ) + `; + } + } + + for (const followup of quotationFollowups) { + await sql` + insert into crm_quotation_followups ( + id, organization_id, quotation_id, followup_date, followup_type, contact_id, outcome, notes, + next_followup_date, next_action, created_at, updated_at, created_by, updated_by + ) values ( + ${followup.id}, ${organization.id}, ${followup.quotationId}, ${followup.followupDate}, ${followup.followupTypeId}, + ${followup.contactId}, ${followup.outcome}, ${followup.notes}, ${followup.nextFollowupDate}, + ${followup.nextAction}, ${followup.followupDate}, ${followup.followupDate}, ${followup.createdBy}, ${followup.updatedBy} + ) + `; + } + + for (const request of approvalRequests) { + await sql` + insert into crm_approval_requests ( + id, organization_id, workflow_id, entity_type, entity_id, current_step, status, + requested_by, requested_at, completed_at, created_at, updated_at + ) values ( + ${request.id}, ${organization.id}, ${workflowId}, ${"quotation"}, ${request.entityId}, ${request.currentStep}, + ${request.status}, ${request.requestedBy}, ${request.requestedAt}, ${request.completedAt}, ${request.createdAt}, ${request.updatedAt} + ) + `; + } + + for (const action of approvalActions) { + await sql` + insert into crm_approval_actions ( + id, organization_id, approval_request_id, step_number, action, remark, acted_by, acted_at, created_at, updated_at + ) values ( + ${action.id}, ${organization.id}, ${action.approvalRequestId}, ${action.stepNumber}, ${action.action}, + ${action.remark}, ${action.actedBy}, ${action.actedAt}, ${action.actedAt}, ${action.actedAt} + ) + `; + } +} + +async function bumpDocumentSequences( + sql: SqlClient, + organizationId: string, + branches: BranchRow[], +) { + const documentTypes = [ + ["customer", 850], + ["crm_lead", 980], + ["crm_opportunity", 1050], + ["quotation", 1160], + ] as const; + + for (const branch of branches) { + for (const [documentType, currentNumber] of documentTypes) { + await sql` + update document_sequences + set current_number = greatest(current_number, ${currentNumber}), updated_at = now() + where organization_id = ${organizationId} + and branch_id = ${branch.id} + and document_type = ${documentType} + and period = ${PERIOD} + `; + } + } +} + +async function seedOrganization(sql: SqlClient, organization: OrganizationRow) { + const [memberships, optionMap, branches, workflowId] = await Promise.all([ + loadMemberships(sql, organization.id), + loadOptions(sql, organization.id), + loadBranches(sql, organization.id), + resolveApprovalWorkflow(sql, organization.id), + ]); + + const users = buildUserContext(memberships); + await cleanupSeededData(sql, organization.id); + + const { customers, storyCustomerMap } = buildBaseCustomers( + optionMap, + branches, + users, + ); + const { contacts, primaryContactByCustomerId } = buildContacts( + customers, + users, + ); + const leadData = buildLeadAndOpportunityData( + optionMap, + storyCustomerMap, + customers, + primaryContactByCustomerId, + users, + ); + const quotationData = buildQuotationData( + optionMap, + leadData.storyOpportunityMap, + storyCustomerMap, + customers, + primaryContactByCustomerId, + users, + ); + + await insertSeedData( + sql, + organization, + workflowId, + customers, + contacts, + leadData.leads, + leadData.leadFollowups, + leadData.opportunities, + leadData.opportunityParties, + leadData.opportunityFollowups, + quotationData.quotations, + quotationData.quotationItems, + quotationData.quotationParties, + quotationData.quotationTopics, + quotationData.quotationFollowups, + quotationData.approvalRequests, + quotationData.approvalActions, + ); + + await bumpDocumentSequences(sql, organization.id, branches); + + console.log( + `[seed:crm-uat] ${organization.name}: ${customers.length} customers, ${leadData.leads.length} leads, ${leadData.opportunities.length} opportunities, ${quotationData.quotations.length} quotations`, + ); +} + +async function main() { + loadLocalEnv(); + const databaseUrl = requireEnv("DATABASE_URL"); + const sql = postgres(databaseUrl, { prepare: false }); + + try { + const organizations = await loadOrganizations(sql); + if (!organizations.length) { + throw new Error( + "No organizations found. Run organization and foundation setup first.", + ); + } + + for (const organization of organizations) { + await seedOrganization(sql, organization); + } + } finally { + await sql.end(); + } +} + +const isDirectRun = + process.argv[1] != null && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; + +if (isDirectRun) { + main().catch((error) => { + console.error( + "[seed:crm-uat] Failed:", + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + }); +} diff --git a/src/features/chat/components/messenger.tsx b/src/features/chat/components/messenger.tsx index d9f4ff1..a93b04e 100644 --- a/src/features/chat/components/messenger.tsx +++ b/src/features/chat/components/messenger.tsx @@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useReducedMotion } from 'motion/react'; +import { formatTime } from '@/lib/date-format'; import { useChatStore } from '../utils/store'; import type { Attachment, Message } from '../utils/types'; import { ConversationList } from './conversation-list'; @@ -72,10 +73,7 @@ export function Messenger() { const delay = shouldReduceMotion ? 0 : 900; replyTimeoutRef.current = window.setTimeout(() => { - const timestamp = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); + const timestamp = formatTime(new Date()); const incoming: Message = { id: 'incoming-' + Date.now().toString(), sender: 'contact', diff --git a/src/features/chat/utils/store.ts b/src/features/chat/utils/store.ts index c8293f1..0570fe2 100644 --- a/src/features/chat/utils/store.ts +++ b/src/features/chat/utils/store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { formatTime } from '@/lib/date-format'; // import { persist } from 'zustand/middleware'; import type { Attachment, Conversation, Message } from './types'; import { initialConversations } from './data'; @@ -38,10 +39,7 @@ export const useChatStore = create()( sendMessage: (text, attachments) => { const state = get(); - const timestamp = new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit' - }); + const timestamp = formatTime(new Date()); const outgoing: Message = { id: 'outgoing-' + Date.now().toString(), sender: 'user', diff --git a/src/features/crm-demo/utils/format.ts b/src/features/crm-demo/utils/format.ts index 96d808e..7637e68 100644 --- a/src/features/crm-demo/utils/format.ts +++ b/src/features/crm-demo/utils/format.ts @@ -6,12 +6,10 @@ export function formatCurrency(value: number) { }).format(value); } +import { formatDate as formatGlobalDate } from '@/lib/date-format'; + export function formatDate(value: string) { - return new Intl.DateTimeFormat('th-TH', { - day: '2-digit', - month: 'short', - year: 'numeric' - }).format(new Date(value)); + return formatGlobalDate(value); } export function formatPercent(value: number) { diff --git a/src/features/crm/customers/components/customer-columns.tsx b/src/features/crm/customers/components/customer-columns.tsx index 9be7ef3..b3c8261 100644 --- a/src/features/crm/customers/components/customer-columns.tsx +++ b/src/features/crm/customers/components/customer-columns.tsx @@ -5,6 +5,7 @@ import type { Column, ColumnDef } from '@tanstack/react-table'; import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { Icons } from '@/components/icons'; +import { formatDate } from '@/lib/date-format'; import type { CustomerListItem, CustomerReferenceData } from '../api/types'; import { CustomerCellAction } from './customer-cell-action'; import { CustomerStatusBadge } from './customer-status-badge'; @@ -140,7 +141,9 @@ export function getCustomerColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {row.original.ownerName ?? userMap.get(row.original.ownerUserId ?? '') ?? '-'}, + cell: ({ row }) => ( + {row.original.ownerName ?? userMap.get(row.original.ownerUserId ?? '') ?? '-'} + ), meta: { label: 'Owner', variant: 'multiSelect' as const, @@ -157,7 +160,13 @@ export function getCustomerColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {row.original.ownerAssignedAt ? new Date(row.original.ownerAssignedAt).toLocaleDateString() : '-'} + cell: ({ row }) => ( + + {row.original.ownerAssignedAt + ? formatDate(row.original.ownerAssignedAt) + : '-'} + + ) }, { id: 'contactCount', diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 28b0c88..62fbccd 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { formatDateTime } from '@/lib/format'; +import { formatNumber } from '@/lib/number-format'; import { customerByIdOptions } from '../api/queries'; import type { CustomerReferenceData } from '../api/types'; import { CustomerFormSheet } from './customer-form-sheet'; @@ -146,10 +147,7 @@ export function CustomerDetail({ label='Branch' value={customer.branchId ? branchMap.get(customer.branchId) : null} /> - + เอกสารที่เกี่ยวข้อง - ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้ + + ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้ + {!relatedOpportunities.length && !relatedQuotations.length ? ( @@ -272,7 +272,7 @@ export function CustomerDetail({
{quotation.code}
- {quotation.totalAmount.toLocaleString()} + {formatNumber(quotation.totalAmount)}
diff --git a/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx b/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx index cf3fca0..17d16c5 100644 --- a/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx +++ b/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx @@ -1,7 +1,15 @@ 'use client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { formatDateTime } from '@/lib/date-format'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; import type { CrmDashboardResponse } from '../api/types'; function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) { @@ -64,7 +72,7 @@ export function DashboardApprovalMetrics({ {row.workflowName} {row.currentStepRoleName ?? '-'} - {new Date(row.requestedAt).toLocaleString()} + {formatDateTime(row.requestedAt)} )) )} diff --git a/src/features/crm/dashboard/components/dashboard-followups.tsx b/src/features/crm/dashboard/components/dashboard-followups.tsx index 2fb0e49..33241b1 100644 --- a/src/features/crm/dashboard/components/dashboard-followups.tsx +++ b/src/features/crm/dashboard/components/dashboard-followups.tsx @@ -1,7 +1,15 @@ 'use client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { formatDate } from '@/lib/date-format'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; import type { CrmDashboardResponse } from '../api/types'; function Stat({ label, value }: { label: string; value: number }) { @@ -53,7 +61,7 @@ export function DashboardFollowups({ ) : ( followups.upcoming.map((row) => ( - {new Date(row.followupDate).toLocaleDateString()} + {formatDate(row.followupDate)} {row.customerName} {row.opportunityName} {row.assignedSalesName ?? '-'} diff --git a/src/features/crm/leads/components/lead-columns.tsx b/src/features/crm/leads/components/lead-columns.tsx index 3c534da..f439089 100644 --- a/src/features/crm/leads/components/lead-columns.tsx +++ b/src/features/crm/leads/components/lead-columns.tsx @@ -5,6 +5,8 @@ import type { Column, ColumnDef } from '@tanstack/react-table'; import { Icons } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { formatDate } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import type { LeadReferenceData, LeadSummary } from '../api/types'; import { LeadCellAction } from './lead-cell-action'; @@ -28,7 +30,10 @@ export function getLeadColumns({ ), cell: ({ row }) => (
- + {row.original.code} {row.original.customerName ?? '-'} @@ -48,7 +53,9 @@ export function getLeadColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {row.original.statusLabel ?? row.original.status}, + cell: ({ row }) => ( + {row.original.statusLabel ?? row.original.status} + ), meta: { label: 'Status', variant: 'select' as const, @@ -136,7 +143,9 @@ export function getLeadColumns({ ), cell: ({ row }) => ( - {row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'} + {row.original.estimatedValue !== null + ? formatNumber(row.original.estimatedValue) + : '-'} ) }, @@ -146,7 +155,7 @@ export function getLeadColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} + cell: ({ row }) => {formatDate(row.original.createdAt)} }, { id: 'actions', diff --git a/src/features/crm/leads/components/lead-detail.tsx b/src/features/crm/leads/components/lead-detail.tsx index 3ce33aa..f1c4bfe 100644 --- a/src/features/crm/leads/components/lead-detail.tsx +++ b/src/features/crm/leads/components/lead-detail.tsx @@ -7,6 +7,8 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { formatDateTime } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import { leadByIdOptions } from '../api/queries'; import type { LeadReferenceData } from '../api/types'; import { LeadAssignmentDialog } from './lead-assignment-dialog'; @@ -42,7 +44,12 @@ export function LeadDetail({ return (
- + @@ -118,7 +125,9 @@ export function LeadDetail({ {lead.linkedOpportunities.length === 0 ? ( -
No linked opportunities yet.
+
+ No linked opportunities yet. +
) : ( lead.linkedOpportunities.map((opportunity) => (
@@ -156,7 +165,7 @@ export function LeadDetail({
{activity.action}
{activity.actorName ?? activity.userId} -{' '} - {new Date(activity.createdAt).toLocaleString()} + {formatDateTime(activity.createdAt)}
)) @@ -174,10 +183,13 @@ export function LeadDetail({ - + @@ -188,4 +200,3 @@ export function LeadDetail({
); } - diff --git a/src/features/crm/leads/components/lead-followup-panel.tsx b/src/features/crm/leads/components/lead-followup-panel.tsx index 18f4fcd..9ccb983 100644 --- a/src/features/crm/leads/components/lead-followup-panel.tsx +++ b/src/features/crm/leads/components/lead-followup-panel.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { formatDate } from '@/lib/date-format'; import type { LeadDetailResponse } from '../api/types'; import { createLeadFollowupMutation } from '../api/mutations'; @@ -101,10 +102,10 @@ export function LeadFollowupPanel({ detail.followups.map((followup) => (
-
{followup.followupStatusLabel ?? followup.followupStatus}
-
- {new Date(followup.followupDate).toLocaleDateString()} +
+ {followup.followupStatusLabel ?? followup.followupStatus}
+
{formatDate(followup.followupDate)}
{followup.note ?
{followup.note}
: null}
diff --git a/src/features/crm/opportunities/components/opportunity-columns.tsx b/src/features/crm/opportunities/components/opportunity-columns.tsx index 3980446..43db143 100644 --- a/src/features/crm/opportunities/components/opportunity-columns.tsx +++ b/src/features/crm/opportunities/components/opportunity-columns.tsx @@ -5,6 +5,8 @@ import type { Column, ColumnDef } from '@tanstack/react-table'; import { Icons } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { formatDate } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import type { OpportunityListItem, OpportunityReferenceData } from '../api/types'; import { OpportunityCellAction } from './opportunity-cell-action'; import { OpportunityStatusBadge } from './opportunity-status-badge'; @@ -36,7 +38,10 @@ function getLeadColumns({ ), cell: ({ row }) => (
- + {row.original.code} {row.original.title} @@ -156,7 +161,9 @@ function getOpportunityWorkspaceColumns({ ), cell: ({ row }) => row.original.leadId ? ( - {row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'} + + {row.original.source ?? row.original.sourceLeadCode ?? 'Lead linked'} + ) : ( Direct sales ) @@ -192,7 +199,9 @@ function getOpportunityWorkspaceColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {productTypeMap.get(row.original.productType) ?? row.original.productType}, + cell: ({ row }) => ( + {productTypeMap.get(row.original.productType) ?? row.original.productType} + ), meta: { label: 'Product Type', variant: 'multiSelect' as const, @@ -239,7 +248,9 @@ function getOpportunityWorkspaceColumns({ ), cell: ({ row }) => ( - {row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'} + {row.original.estimatedValue !== null + ? formatNumber(row.original.estimatedValue) + : '-'} ) }, @@ -249,7 +260,9 @@ function getOpportunityWorkspaceColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} + cell: ({ row }) => ( + {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} + ) }, { id: 'expectedCloseDate', @@ -260,7 +273,7 @@ function getOpportunityWorkspaceColumns({ cell: ({ row }) => ( {row.original.expectedCloseDate - ? new Date(row.original.expectedCloseDate).toLocaleDateString() + ? formatDate(row.original.expectedCloseDate) : '-'} ) @@ -273,7 +286,9 @@ function getOpportunityWorkspaceColumns({ ), cell: ({ row }) => ( - {row.original.nextFollowupDate ? new Date(row.original.nextFollowupDate).toLocaleDateString() : '-'} + {row.original.nextFollowupDate + ? formatDate(row.original.nextFollowupDate) + : '-'} ) }, @@ -283,7 +298,7 @@ function getOpportunityWorkspaceColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} + cell: ({ row }) => {formatDate(row.original.createdAt)} }, { id: 'actions', @@ -311,6 +326,7 @@ export function getOpportunityColumns({ canReassign }: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef[] { const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign }; - return workspace === 'lead' ? getLeadColumns(sharedConfig) : getOpportunityWorkspaceColumns(sharedConfig); + return workspace === 'lead' + ? getLeadColumns(sharedConfig) + : getOpportunityWorkspaceColumns(sharedConfig); } - diff --git a/src/features/crm/opportunities/components/opportunity-detail.tsx b/src/features/crm/opportunities/components/opportunity-detail.tsx index 43a5ae9..2414b67 100644 --- a/src/features/crm/opportunities/components/opportunity-detail.tsx +++ b/src/features/crm/opportunities/components/opportunity-detail.tsx @@ -8,7 +8,12 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types'; +import { formatDate, formatDateTime } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; +import type { + QuotationReferenceData, + QuotationRelationItem +} from '@/features/crm/quotations/api/types'; import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet'; import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology'; import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries'; @@ -76,7 +81,9 @@ export function OpportunityDetail({ quotationReferenceData = null }: OpportunityDetailProps) { const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId)); - const { data: projectPartiesData } = useSuspenseQuery(opportunityProjectPartiesOptions(opportunityId)); + const { data: projectPartiesData } = useSuspenseQuery( + opportunityProjectPartiesOptions(opportunityId) + ); const [editOpen, setEditOpen] = useState(false); const [assignmentOpen, setAssignmentOpen] = useState(false); const [quotationOpen, setQuotationOpen] = useState(false); @@ -142,7 +149,10 @@ export function OpportunityDetail({

{opportunity.code}

- +

Opportunity detail for sales execution, follow-up continuity, and quotation readiness. @@ -193,13 +203,17 @@ export function OpportunityDetail({ label='Expected Close Date' value={ opportunity.expectedCloseDate - ? new Date(opportunity.expectedCloseDate).toLocaleDateString() + ? formatDate(opportunity.expectedCloseDate) : null } /> @@ -216,7 +230,10 @@ export function OpportunityDetail({ - + @@ -240,8 +257,14 @@ export function OpportunityDetail({ Sales Qualification - - + + @@ -280,7 +305,9 @@ export function OpportunityDetail({ {!canViewRelatedQuotations ? ( -

Quotation access is restricted.
+
+ Quotation access is restricted. +
) : relatedQuotations.length === 0 ? (
No quotations created yet.
) : ( @@ -317,7 +344,7 @@ export function OpportunityDetail({
{activity.action}
{activity.actorName ?? activity.userId} -{' '} - {new Date(activity.createdAt).toLocaleString()} + {formatDateTime(activity.createdAt)}
)) @@ -346,23 +373,27 @@ export function OpportunityDetail({ + - {relatedQuotations.length ? (
{relatedQuotations.slice(0, 3).map((quotation) => (
- + {quotation.code} Rev {quotation.revision}
- {quotation.status} · {quotation.totalAmount.toLocaleString()} {quotation.currency} + {quotation.status} · {formatNumber(quotation.totalAmount)}{' '} + {quotation.currency}
))} diff --git a/src/features/crm/opportunities/components/opportunity-followups-tab.tsx b/src/features/crm/opportunities/components/opportunity-followups-tab.tsx index 72c5c21..43df341 100644 --- a/src/features/crm/opportunities/components/opportunity-followups-tab.tsx +++ b/src/features/crm/opportunities/components/opportunity-followups-tab.tsx @@ -8,6 +8,7 @@ import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { formatDate } from '@/lib/date-format'; import { deleteOpportunityFollowupMutation } from '../api/mutations'; import { opportunityFollowupsOptions } from '../api/queries'; import type { OpportunityReferenceData } from '../api/types'; @@ -42,8 +43,7 @@ export function OpportunityFollowupsTab({ setDeleteOpen(false); setSelectedId(null); }, - onError: (error) => - toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้') + onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบงานติดตามได้') }); return ( @@ -82,16 +82,18 @@ export function OpportunityFollowupsTab({ {followupTypeMap.get(item.followupType) ?? 'Unknown type'} - {new Date(item.followupDate).toLocaleDateString()} + {formatDate(item.followupDate)}
{item.outcome ?

{item.outcome}

: null} {item.nextAction ? ( -

การดำเนินการถัดไป: {item.nextAction}

+

+ การดำเนินการถัดไป: {item.nextAction} +

) : null} {item.nextFollowupDate ? (

- นัดติดตามครั้งถัดไป: {new Date(item.nextFollowupDate).toLocaleDateString()} + นัดติดตามครั้งถัดไป: {formatDate(item.nextFollowupDate)}

) : null} {item.notes ?

{item.notes}

: null} diff --git a/src/features/crm/opportunities/components/opportunity-outcome-card.tsx b/src/features/crm/opportunities/components/opportunity-outcome-card.tsx index eab7fb6..b1e51e5 100644 --- a/src/features/crm/opportunities/components/opportunity-outcome-card.tsx +++ b/src/features/crm/opportunities/components/opportunity-outcome-card.tsx @@ -18,6 +18,8 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { formatDate, formatDateTime } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import { Select, SelectContent, @@ -117,7 +119,8 @@ export function OpportunityOutcomeCard({ setReopenOpen(false); await invalidateOpportunityMutationQueries(opportunityId); }, - onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity') + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity') }); const uploadMutation = useMutation({ mutationFn: async () => { @@ -171,34 +174,55 @@ export function OpportunityOutcomeCard({ Outcome - Official won/lost lifecycle and purchase order tracking. + + Official won/lost lifecycle and purchase order tracking. +
- + {getOutcomeLabel(opportunity.pipelineStage)}
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? ( - ) : null} {opportunity.pipelineStage === 'opportunity' && canMarkLost ? ( - @@ -215,18 +239,18 @@ export function OpportunityOutcomeCard({ {attachment.originalFileName}
{attachment.fileType ?? 'Unknown type'} - {attachment.fileSize ? ` • ${attachment.fileSize.toLocaleString()} bytes` : ''} + {attachment.fileSize + ? ` • ${formatNumber(attachment.fileSize)} bytes` + : ''}
@@ -318,12 +348,18 @@ export function OpportunityOutcomeCard({ Mark Won - PO received is the official trigger for closed won. + + PO received is the official trigger for closed won. +
- setPoNumber(e.target.value)} /> + setPoNumber(e.target.value)} + />
@@ -391,7 +427,9 @@ export function OpportunityOutcomeCard({ Mark Lost - Lost reason is required for every closed lost outcome. + + Lost reason is required for every closed lost outcome. +
@@ -482,4 +520,3 @@ export function OpportunityOutcomeCard({ ); } - diff --git a/src/features/crm/quotations/components/quotation-columns.tsx b/src/features/crm/quotations/components/quotation-columns.tsx index b5b27ee..05e7bcb 100644 --- a/src/features/crm/quotations/components/quotation-columns.tsx +++ b/src/features/crm/quotations/components/quotation-columns.tsx @@ -5,6 +5,7 @@ import type { Column, ColumnDef } from '@tanstack/react-table'; import { Badge } from '@/components/ui/badge'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { Icons } from '@/components/icons'; +import { formatNumber } from '@/lib/number-format'; import type { QuotationListItem, QuotationReferenceData } from '../api/types'; import { QuotationCellAction } from './quotation-cell-action'; import { QuotationStatusBadge } from './quotation-status-badge'; @@ -31,7 +32,10 @@ export function getQuotationColumns({ ), cell: ({ row }) => (
- + {row.original.code} @@ -90,7 +94,11 @@ export function getQuotationColumns({ ), cell: ({ row }) => ( - {row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'} + + {row.original.branchId + ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') + : 'Unassigned'} + ), meta: { label: 'สาขา', @@ -123,7 +131,10 @@ export function getQuotationColumns({ meta: { label: 'โอกาสขาย', variant: 'multiSelect' as const, - options: referenceData.opportunities.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` })) + options: referenceData.opportunities.map((item) => ({ + value: item.id, + label: `${item.code} - ${item.title}` + })) }, enableColumnFilter: true }, @@ -150,7 +161,7 @@ export function getQuotationColumns({ header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => row.original.totalAmount.toLocaleString() + cell: ({ row }) => formatNumber(row.original.totalAmount) }, { id: 'actions', @@ -165,4 +176,3 @@ export function getQuotationColumns({ } ]; } - diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index 4b3eeec..2b17e24 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -1,45 +1,41 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { useMemo, useState } from "react"; -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; -import { AlertModal } from "@/components/modal/alert-modal"; -import { Icons } from "@/components/icons"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Separator } from "@/components/ui/separator"; + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; import { Select, SelectContent, SelectItem, SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { formatDate, formatDateTime } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import { CrmCurrencyInput, CrmDateInput, CrmNumberInput, CrmPercentageInput, - CrmTextarea, -} from "@/features/crm/components/crm-form-controls"; + CrmTextarea +} from '@/features/crm/components/crm-form-controls'; import { createQuotationAttachmentMutation, createQuotationCustomerMutation, @@ -56,8 +52,8 @@ import { updateQuotationCustomerMutation, updateQuotationFollowupMutation, updateQuotationItemMutation, - updateQuotationTopicMutation, -} from "../api/mutations"; + updateQuotationTopicMutation +} from '../api/mutations'; import { quotationAttachmentsOptions, quotationByIdOptions, @@ -66,8 +62,8 @@ import { quotationItemsOptions, quotationKeys, quotationRevisionsOptions, - quotationTopicsOptions, -} from "../api/queries"; + quotationTopicsOptions +} from '../api/queries'; import type { QuotationAttachmentMutationPayload, QuotationAttachmentRecord, @@ -79,34 +75,28 @@ import type { QuotationItemRecord, QuotationReferenceData, QuotationTopicMutationPayload, - QuotationTopicRecord, -} from "../api/types"; -import { submitQuotationApprovalMutation } from "@/features/foundation/approval/mutations"; -import { quotationDocumentKeys } from "@/features/crm/quotations/document/queries"; -import { QuotationDocumentPreview } from "./quotation-document-preview"; -import { QuotationFormSheet } from "./quotation-form-sheet"; -import { QuotationApprovalTab } from "./quotation-approval-tab"; -import { QuotationStatusBadge } from "./quotation-status-badge"; -import { getQueryClient } from "@/lib/query-client"; + QuotationTopicRecord +} from '../api/types'; +import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; +import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries'; +import { QuotationDocumentPreview } from './quotation-document-preview'; +import { QuotationFormSheet } from './quotation-form-sheet'; +import { QuotationApprovalTab } from './quotation-approval-tab'; +import { QuotationStatusBadge } from './quotation-status-badge'; +import { getQueryClient } from '@/lib/query-client'; -function FieldItem({ - label, - value, -}: { - label: string; - value: string | null | undefined; -}) { +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( -
-
{label}
-
{value ?? "-"}
+
+
{label}
+
{value ?? '-'}
); } function EmptyState({ message }: { message: string }) { return ( -
+
{message}
); @@ -118,7 +108,7 @@ function ItemDialog({ onSubmit, pending, referenceData, - item, + item }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -128,35 +118,31 @@ function ItemDialog({ item?: QuotationItemRecord; }) { const [values, setValues] = useState({ - productType: item?.productType ?? referenceData.productTypes[0]?.id ?? "", - description: item?.description ?? "", + productType: item?.productType ?? referenceData.productTypes[0]?.id ?? '', + description: item?.description ?? '', quantity: String(item?.quantity ?? 1), - unit: item?.unit ?? "", + unit: item?.unit ?? '', unitPrice: String(item?.unitPrice ?? 0), discount: String(item?.discount ?? 0), - discountType: item?.discountType ?? "", + discountType: item?.discountType ?? '', taxRate: String(item?.taxRate ?? 0), - notes: item?.notes ?? "", + notes: item?.notes ?? '' }); return ( - {item ? "Edit Item" : "Add Item"} - - Line items drive quotation totals from the server. - + {item ? 'Edit Item' : 'Add Item'} + Line items drive quotation totals from the server. -
+
- setValues((s) => ({ ...s, description: e.target.value })) - } - placeholder="Description" + onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))} + placeholder='Description' /> -
+
- setValues((s) => ({ ...s, quantity: value })) - } - placeholder="Quantity" + onChange={(value) => setValues((s) => ({ ...s, quantity: value }))} + placeholder='Quantity' />
-
+
- setValues((s) => ({ ...s, unitPrice: value })) - } - placeholder="Unit price" - currencyLabel="THB" + onChange={(value) => setValues((s) => ({ ...s, unitPrice: value }))} + placeholder='Unit price' + currencyLabel='THB' /> - setValues((s) => ({ ...s, discount: value })) - } - placeholder="Discount" - currencyLabel="THB" + onChange={(value) => setValues((s) => ({ ...s, discount: value }))} + placeholder='Discount' + currencyLabel='THB' />
-
+
- setValues((s) => ({ ...s, taxRate: value })) - } - placeholder="Tax rate %" + onChange={(value) => setValues((s) => ({ ...s, taxRate: value }))} + placeholder='Tax rate %' />
- setValues((s) => ({ ...s, notes: e.target.value })) - } - placeholder="Notes" - size="md" + onChange={(e) => setValues((s) => ({ ...s, notes: e.target.value }))} + placeholder='Notes' + size='md' />
- - - {quotation.code} - - + {quotation.code} + {quotation.isHotProject ? Hot : null}
-

- {quotation.projectName || "Untitled quotation"} +

+ {quotation.projectName || 'Untitled quotation'}

-

- {customer?.name ?? "Unknown customer"} - {contact ? ` - ${contact.name}` : ""} +

+ {customer?.name ?? 'Unknown customer'} + {contact ? ` - ${contact.name}` : ''}

-
+
{canSubmitCurrentQuotation ? ( ) : null} {canCreateRevision && canReviseByStatus ? ( ) : null} {canUpdate ? ( - ) : null} {canPreviewPdf ? ( - ) : null} {canDownloadPdf ? ( - ) : null} {canGenerateApprovedNow ? ( ) : null} {quotation.approvedPdfUrl ? ( - ) : null}
-
-
+
+
- - - - Overview - Items - ผู้เกี่ยวข้องในโครงการ - Topics - Follow-ups - Attachments - Activity - Approval - Document Preview + + + + Overview + Items + ผู้เกี่ยวข้องในโครงการ + Topics + Follow-ups + Attachments + Activity + Approval + Document Preview - + Overview @@ -1347,129 +1187,92 @@ export function QuotationDetail({ Commercial header data and quotation summary. - + + + - + - - + - + + + - - - -
-
-
- ผู้เกี่ยวข้องในโครงการ -
+
+
+
ผู้เกี่ยวข้องในโครงการ
{!customersData.items.length ? ( -
+
No project parties have been added yet.
) : ( -
+
{customersData.items.map((item) => ( -
-
- {item.customerName} -
-
+
+
{item.customerName}
+
Role: {item.roleLabel ?? item.roleCode}
- {item.remark ? ( -
{item.remark}
- ) : null} + {item.remark ?
{item.remark}
: null}
))}
)}
-
+
-
+
-
- +
+
- + - +
Items @@ -1483,48 +1286,45 @@ export function QuotationDetail({ setItemOpen(true); }} > - Add Item + Add Item ) : null} - + {!itemsData.items.length ? ( - + ) : ( itemsData.items.map((item) => ( -
-
-
-
- {item.description} +
+
+
+
{item.description}
+
+ Qty {item.quantity} {item.unit || ''} x{' '} + {formatNumber(item.unitPrice)}
-
- Qty {item.quantity} {item.unit || ""} x{" "} - {item.unitPrice.toLocaleString()} -
-
- Total {item.totalPrice.toLocaleString()} +
+ Total {formatNumber(item.totalPrice)}
{canManageItems ? ( -
+
) : null} @@ -1536,14 +1336,13 @@ export function QuotationDetail({ - + - +
ผู้เกี่ยวข้องในโครงการ - Related companies for this quotation, each with a - visible role. + Related companies for this quotation, each with a visible role.
{canManageCustomers ? ( @@ -1553,51 +1352,48 @@ export function QuotationDetail({ setCustomerOpen(true); }} > - Add Party + Add Party ) : null}
- + {!customersData.items.length ? ( - + ) : ( customersData.items.map((item) => (
-
- {item.customerName} -
-
+
{item.customerName}
+
Role: {item.roleLabel ?? item.roleCode}
{item.remark ? ( -
+
{item.remark}
) : null}
{canManageCustomers ? ( -
+
) : null} @@ -1608,14 +1404,13 @@ export function QuotationDetail({ - + - +
Topics - Scope, exclusions, payment terms, and other document - sections. + Scope, exclusions, payment terms, and other document sections.
{canManageTopics ? ( @@ -1625,48 +1420,46 @@ export function QuotationDetail({ setTopicOpen(true); }} > - Add Topic + Add Topic ) : null}
- + {!topicsData.items.length ? ( - + ) : ( topicsData.items.map((topic) => ( -
-
+
+
-
{topic.title}
-
- {topicTypeMap.get(topic.topicType)?.label ?? - topic.topicType} +
{topic.title}
+
+ {topicTypeMap.get(topic.topicType)?.label ?? topic.topicType}
{canManageTopics ? ( -
+
) : null}
-
    +
      {topic.items.map((item) => (
    • {item.content}
    • ))} @@ -1678,14 +1471,12 @@ export function QuotationDetail({ - + - +
      Follow-ups - - Commercial chasing and response history. - + Commercial chasing and response history.
      {canManageFollowups ? ( ) : null}
      - + {!followupsData.items.length ? ( - + ) : ( followupsData.items.map((item) => ( -
      -
      -
      -
      - {followupTypeMap.get(item.followupType) - ?.label ?? item.followupType} +
      +
      +
      +
      + {followupTypeMap.get(item.followupType)?.label ?? + item.followupType}
      -
      - {new Date( - item.followupDate, - ).toLocaleDateString()} - {item.outcome ? ` - ${item.outcome}` : ""} -
      -
      - {item.notes || "-"} +
      + {formatDate(item.followupDate)} + {item.outcome ? ` - ${item.outcome}` : ''}
      +
      {item.notes || '-'}
      {canManageFollowups ? ( -
      +
      ) : null} @@ -1750,14 +1536,13 @@ export function QuotationDetail({ - + - +
      Attachments - Metadata only in Task E, ready for storage integration - later. + Metadata only in Task E, ready for storage integration later.
      {canManageAttachments ? ( @@ -1767,49 +1552,44 @@ export function QuotationDetail({ setAttachmentOpen(true); }} > - Add Attachment + Add Attachment ) : null}
      - + {!attachmentsData.items.length ? ( - + ) : ( attachmentsData.items.map((item) => (
      -
      - {item.originalFileName} -
      -
      - {item.fileType || "Unknown type"} - {item.fileSize - ? ` - ${item.fileSize.toLocaleString()} bytes` - : ""} +
      {item.originalFileName}
      +
      + {item.fileType || 'Unknown type'} + {item.fileSize ? ` - ${formatNumber(item.fileSize)} bytes` : ''}
      {canManageAttachments ? ( -
      +
      ) : null} @@ -1820,7 +1600,7 @@ export function QuotationDetail({ - + Activity @@ -1828,37 +1608,32 @@ export function QuotationDetail({ Audit events for quotation mutations and child records. - + {!data.activity.length ? ( - + ) : ( data.activity.map((item, index) => ( -
      -
      -
      -
      -
      - +
      +
      +
      +
      +
      + {item.action} - - {item.entityType.replaceAll("_", " ")} + + {item.entityType.replaceAll('_', ' ')} - + {item.actorName ?? item.userId}
      -
      - {new Date(item.createdAt).toLocaleString()} +
      + {formatDateTime(item.createdAt)}
      - {index < data.activity.length - 1 ? ( - - ) : null} + {index < data.activity.length - 1 ? : null}
      )) )} @@ -1866,7 +1641,7 @@ export function QuotationDetail({ - + - + {canPreviewDocument ? ( ) : ( @@ -1892,7 +1667,7 @@ export function QuotationDetail({ - + )} @@ -1902,7 +1677,7 @@ export function QuotationDetail({
      -
      +
      Revision Chain @@ -1910,24 +1685,24 @@ export function QuotationDetail({ Family of quotations copied from the same parent record. - + {!revisionsData.items.length ? ( - + ) : ( revisionsData.items.map((item) => ( -
      +
      -
      {item.code}
      -
      - {item.totalAmount.toLocaleString()} +
      {item.code}
      +
      + {formatNumber(item.totalAmount)}
      - +
      )) @@ -1938,43 +1713,39 @@ export function QuotationDetail({ Record Snapshot - - Operational metadata for this quotation row. - + Operational metadata for this quotation row. - - - + + + -
      -
      - Artifact Status -
      -
      +
      +
      Artifact Status
      +
      {quotation.approvedArtifact ? ( {quotation.approvedArtifact.status} ) : ( - - + - )} {quotation.approvedArtifact?.lockedAt ? ( - - Locked{" "} - {new Date( - quotation.approvedArtifact.lockedAt, - ).toLocaleString()} + + Locked {formatDateTime(quotation.approvedArtifact.lockedAt)} ) : null}
      {quotation.hasLegacyApprovedPdf ? ( -
      -
      - Legacy approved PDF detected -
      -
      - This quotation still points to the old `public/generated` - storage path and has not been migrated into the artifact - storage model yet. +
      +
      Legacy approved PDF detected
      +
      + This quotation still points to the old `public/generated` storage path and has + not been migrated into the artifact storage model yet.
      ) : null} diff --git a/src/features/crm/quotations/components/quotation-document-preview.tsx b/src/features/crm/quotations/components/quotation-document-preview.tsx index 31dbed7..da8ff23 100644 --- a/src/features/crm/quotations/components/quotation-document-preview.tsx +++ b/src/features/crm/quotations/components/quotation-document-preview.tsx @@ -4,6 +4,8 @@ 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 { formatDate, formatDateTime } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import { quotationDocumentPreviewOptions } from '@/features/crm/quotations/document/queries'; function Field({ label, value }: { label: string; value: string | null | undefined }) { @@ -58,13 +60,13 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string @@ -131,8 +133,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
      {item.quantity}
      {item.unitLabel || '-'}
      -
      {item.unitPrice.toLocaleString()}
      -
      {item.totalPrice.toLocaleString()}
      +
      {formatNumber(item.unitPrice)}
      +
      {formatNumber(item.totalPrice)}
      ))} @@ -183,16 +185,16 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
      ก่อนภาษี - {documentData.quotation.subtotal.toLocaleString()} + {formatNumber(documentData.quotation.subtotal)}
      ภาษี - {documentData.quotation.taxAmount.toLocaleString()} + {formatNumber(documentData.quotation.taxAmount)}
      รวมทั้งสิ้น - {documentData.quotation.totalAmount.toLocaleString()} + {formatNumber(documentData.quotation.totalAmount)}
      @@ -221,7 +223,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
      {item.actorName || '-'}{' '} - {item.actedAt ? `เมื่อ ${new Date(item.actedAt).toLocaleString()}` : ''} + {item.actedAt ? `เมื่อ ${formatDateTime(item.actedAt)}` : ''}
      )) diff --git a/src/features/crm/quotations/document/server/pdfme-transforms.ts b/src/features/crm/quotations/document/server/pdfme-transforms.ts index d81bc84..cb136d7 100644 --- a/src/features/crm/quotations/document/server/pdfme-transforms.ts +++ b/src/features/crm/quotations/document/server/pdfme-transforms.ts @@ -30,20 +30,7 @@ export function formatPdfDate( return '-'; } - if (language === 'th') { - return date.toLocaleDateString('th-TH', { - day: 'numeric', - month: 'short', - year: 'numeric', - calendar: 'buddhist' - }); - } - - return date.toLocaleDateString('en-US', { - day: 'numeric', - month: 'short', - year: 'numeric' - }); + return formatDate(date); } export function formatPdfCurrency( @@ -191,3 +178,4 @@ export function normalizePdfmeTable( return rows.length ? rows : EMPTY_TABLE_FALLBACK; } +import { formatDate } from '@/lib/date-format'; diff --git a/src/features/crm/reports/components/aging-report-view.tsx b/src/features/crm/reports/components/aging-report-view.tsx index bab1987..549c1b7 100644 --- a/src/features/crm/reports/components/aging-report-view.tsx +++ b/src/features/crm/reports/components/aging-report-view.tsx @@ -4,6 +4,8 @@ import { useMemo } from 'react'; import { parseAsString, useQueryStates } from 'nuqs'; import { useSuspenseQuery } from '@tanstack/react-query'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; import { Table, TableBody, @@ -12,10 +14,7 @@ import { TableHeader, TableRow } from '@/components/ui/table'; -import type { - CrmOpportunityAgingReportResponse, - CrmLeadAgingReportResponse -} from '../api/types'; +import type { CrmOpportunityAgingReportResponse, CrmLeadAgingReportResponse } from '../api/types'; import { crmOpportunityAgingReportQueryOptions, crmLeadAgingReportQueryOptions, @@ -23,10 +22,6 @@ import { } from '../api/queries'; import { ReportFilterBar } from './report-filter-bar'; -function formatNumber(value: number) { - return new Intl.NumberFormat('en-US').format(value); -} - function AgingReportLayout({ variant, canExport, @@ -67,8 +62,16 @@ function AgingReportLayout({ reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'} pageLinks={[ { href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' }, - { href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging', active: variant === 'lead' }, - { href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging', active: variant === 'opportunity' } + { + href: '/dashboard/crm/reports/lead-aging', + label: 'Lead Aging', + active: variant === 'lead' + }, + { + href: '/dashboard/crm/reports/opportunity-aging', + label: 'Opportunity Aging', + active: variant === 'opportunity' + } ]} /> @@ -87,7 +90,9 @@ function AgingReportLayout({ - {variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'} + + {variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'} + Bucketed view to spot stagnant records quickly. @@ -142,7 +147,9 @@ function AgingReportLayout({ {row.leadCode} {row.customerName} {row.assignedUserName ?? '-'} - {new Date(row.createdDate).toLocaleDateString('en-CA')} + + {formatDate(row.createdDate)} + {formatNumber(row.agingDays)} ) : ( @@ -152,7 +159,7 @@ function AgingReportLayout({ {row.salesmanName ?? '-'} {row.lastFollowupDate - ? new Date(row.lastFollowupDate).toLocaleDateString('en-CA') + ? formatDate(row.lastFollowupDate) : '-'} {formatNumber(row.agingDays)} @@ -240,4 +247,3 @@ export function AgingReportView({ ); } - diff --git a/src/features/crm/shared/formats.ts b/src/features/crm/shared/formats.ts index fb65021..3fc1f51 100644 --- a/src/features/crm/shared/formats.ts +++ b/src/features/crm/shared/formats.ts @@ -1,4 +1,6 @@ -import { format, isValid, parse, parseISO } from 'date-fns'; +import { isValid, parse, parseISO } from 'date-fns'; +import { formatDate } from '@/lib/date-format'; +import { formatNumber } from '@/lib/number-format'; function coerceDate(value: string | Date | null | undefined) { if (!value) { @@ -20,21 +22,14 @@ function coerceDate(value: string | Date | null | undefined) { export function formatCrmDate(value: string | Date | null | undefined) { const date = coerceDate(value); - return date ? format(date, 'dd/MM/yyyy') : ''; + return date ? formatDate(date) : ''; } -export function formatCrmNumber(value: number | string | null | undefined, options?: Intl.NumberFormatOptions) { - if (value === '' || value === null || value === undefined) { - return ''; - } - - const parsed = typeof value === 'number' ? value : Number(String(value).replaceAll(',', '')); - - if (Number.isNaN(parsed)) { - return ''; - } - - return new Intl.NumberFormat('en-US', options).format(parsed); +export function formatCrmNumber( + value: number | string | null | undefined, + options?: Intl.NumberFormatOptions +) { + return formatNumber(value, options); } export function sanitizeDecimalInput(value: string) { @@ -42,6 +37,5 @@ export function sanitizeDecimalInput(value: string) { const [head = '', ...tail] = normalized.split('.'); const integerPart = head.replace(/(?!^)-/g, ''); const decimalPart = tail.join(''); - return decimalPart.length > 0 ? `${integerPart}.${decimalPart}` : integerPart; } diff --git a/src/features/example-dashboard/components/example-product-table.tsx b/src/features/example-dashboard/components/example-product-table.tsx index 5338a94..828cb21 100644 --- a/src/features/example-dashboard/components/example-product-table.tsx +++ b/src/features/example-dashboard/components/example-product-table.tsx @@ -5,6 +5,7 @@ import { DataTable } from '@/components/ui/table/data-table'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; import { useDataTable } from '@/hooks/use-data-table'; +import { formatNumber } from '@/lib/number-format'; import { getSortingStateParser } from '@/lib/parsers'; import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; import { useSuspenseQuery } from '@tanstack/react-query'; @@ -60,7 +61,7 @@ const columns: ColumnDef[] = [ { accessorKey: 'price', header: 'PRICE', - cell: ({ row }) => `$${row.original.price.toLocaleString()}` + cell: ({ row }) => `$${formatNumber(row.original.price)}` }, { accessorKey: 'description', diff --git a/src/features/foundation/approval/components/approval-columns.tsx b/src/features/foundation/approval/components/approval-columns.tsx index 258f6e7..8de55fe 100644 --- a/src/features/foundation/approval/components/approval-columns.tsx +++ b/src/features/foundation/approval/components/approval-columns.tsx @@ -4,6 +4,7 @@ import Link from 'next/link'; import type { Column, ColumnDef } from '@tanstack/react-table'; import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; import { Icons } from '@/components/icons'; +import { formatDateTime } from '@/lib/date-format'; import type { ApprovalListItem } from '../types'; import { ApprovalStatusBadge } from './approval-status-badge'; @@ -17,7 +18,10 @@ export function getApprovalColumns(): ColumnDef[] { ), cell: ({ row }) => (
      - + {row.original.entityCode ?? row.original.entityId} @@ -97,7 +101,7 @@ export function getApprovalColumns(): ColumnDef[] { header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString() + cell: ({ row }) => formatDateTime(row.original.requestedAt) } ]; } diff --git a/src/features/foundation/approval/components/approval-request-panel.tsx b/src/features/foundation/approval/components/approval-request-panel.tsx index 064625e..eb68b47 100644 --- a/src/features/foundation/approval/components/approval-request-panel.tsx +++ b/src/features/foundation/approval/components/approval-request-panel.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Textarea } from '@/components/ui/textarea'; +import { formatDateTime } from '@/lib/date-format'; import { approveApprovalMutation, cancelApprovalMutation, @@ -52,8 +53,7 @@ export function ApprovalRequestPanel({ const [remark, setRemark] = useState(''); const isPending = approval.request.status === 'pending'; const canHandleCurrentStep = - !!approval.currentStep && - (isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode); + !!approval.currentStep && (isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode); const canCancelCurrentRequest = canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId); @@ -63,8 +63,7 @@ export function ApprovalRequestPanel({ toast.success('อนุมัติขั้นตอนนี้สำเร็จ'); setRemark(''); }, - onError: (error) => - toast.error(error instanceof Error ? error.message : 'ไม่สามารถอนุมัติคำขอได้') + onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถอนุมัติคำขอได้') }); const rejectAction = useMutation({ ...rejectApprovalMutation, @@ -72,8 +71,7 @@ export function ApprovalRequestPanel({ toast.success('ไม่อนุมัติคำขอสำเร็จ'); setRemark(''); }, - onError: (error) => - toast.error(error instanceof Error ? error.message : 'ไม่สามารถไม่อนุมัติคำขอได้') + onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถไม่อนุมัติคำขอได้') }); const returnAction = useMutation({ ...returnApprovalMutation, @@ -81,14 +79,12 @@ export function ApprovalRequestPanel({ toast.success('ตีกลับคำขอเพื่อแก้ไขสำเร็จ'); setRemark(''); }, - onError: (error) => - toast.error(error instanceof Error ? error.message : 'ไม่สามารถตีกลับคำขอได้') + onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถตีกลับคำขอได้') }); const cancelAction = useMutation({ ...cancelApprovalMutation, onSuccess: () => toast.success('ยกเลิกคำขออนุมัติสำเร็จ'), - onError: (error) => - toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกคำขอได้') + onError: (error) => toast.error(error instanceof Error ? error.message : 'ไม่สามารถยกเลิกคำขอได้') }); const isActing = approveAction.isPending || @@ -109,12 +105,15 @@ export function ApprovalRequestPanel({
      สถานะ
      - + {approval.steps.map((step) => { const isCurrent = approval.currentStep?.id === step.id && isPending; - const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved'; + const isCompleted = + approval.request.currentStep > step.stepNumber || + approval.request.status === 'approved'; return (
      การดำเนินการ - - ผู้อนุมัติสามารถอนุมัติ ไม่อนุมัติ หรือตีกลับในขั้นที่รับผิดชอบได้ - + ผู้อนุมัติสามารถอนุมัติ ไม่อนุมัติ หรือตีกลับในขั้นที่รับผิดชอบได้