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