-
{user.name}
-
{user.email}
+
+
+
+
{user.name}
+
+ {user.email}
+
- router.push('/dashboard/profile')}>
+ router.push("/dashboard/profile")}>
Profile
- Billing
+
Settings
New Team
- void signOut({ redirectTo: '/auth/sign-in' })}>
+ void signOut({ redirectTo: "/auth/sign-in" })}
+ >
Sign out
diff --git a/src/db/seeds/crm-uat.seed.ts b/src/db/seeds/crm-uat.seed.ts
index 78f1291..fc26aa8 100644
--- a/src/db/seeds/crm-uat.seed.ts
+++ b/src/db/seeds/crm-uat.seed.ts
@@ -345,6 +345,7 @@ type UserContext = {
admin: UserMembershipRow;
marketing: UserMembershipRow;
sales: UserMembershipRow[];
+ salesByProductType: Partial
>;
salesManager: UserMembershipRow;
departmentManager: UserMembershipRow;
topManager: UserMembershipRow;
@@ -352,33 +353,41 @@ type UserContext = {
const SEED_TAG = "crm-uat-seed-v1";
const PERIOD = "2606";
+const UAT_EMAILS = {
+ marketing: "mk.uat@alla.local",
+ salesCrane: "sales.crane.uat@alla.local",
+ salesDockDoor: "sales.dockdoor.uat@alla.local",
+ salesManager: "mgr.sales.uat@alla.local",
+ topManager: "ceo.uat@alla.local",
+ admin: "admin.uat@alla.local",
+} as const;
const STORIES: StoryConfig[] = [
{
slug: "website-warehouse-crane-win",
- title: "Lead received from website and won after approval",
+ title: "Siam Manufacturing crane project waiting final CEO approval",
customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด",
- province: "Bangkok",
- district: "Min Buri",
+ province: "Chachoengsao",
+ district: "Bang Pakong",
branchCode: "head_office",
customerGroupCode: "industrial",
customerSubGroupCode: "factory",
productTypeCode: "crane",
quotationTypeCode: "crane",
priorityCode: "high",
- leadChannelCode: "website",
+ leadChannelCode: "referral",
awarenessCode: "google_search_website",
leadStatusCode: "assigned",
- leadFollowupStatusCode: "waiting_po",
+ leadFollowupStatusCode: "drawing_followup",
opportunityStatusCode: "negotiation",
- chancePercent: 80,
- estimatedValue: 12400000,
- projectName: "Warehouse Crane Installation Phase 2",
- projectLocation: "Bangkok Food Industrial Estate, Min Buri",
+ chancePercent: 78,
+ estimatedValue: 8750000,
+ projectName: "Overhead Crane Installation Phase 1",
+ projectLocation: "Toyota Gateway Plant",
leadDescription:
- "Customer asked for top-running overhead crane to support raw material warehouse expansion.",
- quotePlan: "accepted",
- opportunityOutcome: "won",
+ "Marketing qualified the crane project and handed it to the crane sales queue for quotation approval.",
+ quotePlan: "pending_step_2",
+ opportunityOutcome: "open",
multiParties: [
{
roleCode: "contractor",
@@ -392,57 +401,57 @@ const STORIES: StoryConfig[] = [
},
{
slug: "facebook-lost-price",
- title: "Lead lost to competitor because price too high",
+ title: "Demo lost customer with no quotation because budget not approved",
customerName: "บริษัท ไทย ออโต้ เพรส จำกัด",
- province: "Samut Prakan",
- district: "Bang Phli",
- branchCode: "factory",
+ province: "Bangkok",
+ district: "Lat Krabang",
+ branchCode: "service",
customerGroupCode: "industrial",
- customerSubGroupCode: "factory",
- productTypeCode: "crane",
- quotationTypeCode: "crane",
+ customerSubGroupCode: "warehouse",
+ productTypeCode: "dockdoor",
+ quotationTypeCode: "dockdoor",
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",
+ leadChannelCode: "website",
+ awarenessCode: "google_search_website",
+ leadStatusCode: "cancel",
+ leadFollowupStatusCode: "budget_pending",
+ opportunityStatusCode: "qualification",
+ chancePercent: 20,
+ estimatedValue: 1900000,
+ projectName: "Budget Hold Demo Project",
+ projectLocation: "Lat Krabang Logistics Zone",
leadDescription:
- "Customer compared 5 ton monorail hoist replacement between ALLA and Konecranes.",
- competitor: "Konecranes",
- quotePlan: "sent_follow_up",
- opportunityOutcome: "lost",
- lostReasonCode: "price_too_high",
+ "Opportunity was stopped before quotation because customer budget was not approved and competitor was still under consideration.",
+ competitor: "Competitor Selected",
+ quotePlan: "cancelled",
+ opportunityOutcome: "no_quotation",
+ noQuotationReasonCode: "budget_not_ready",
},
{
slug: "revision-r02-approved",
- title: "Customer requested revision and approved revision R02",
+ title: "Eastern Logistics dock door project in draft and follow-up stage",
customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด",
- province: "Pathum Thani",
- district: "Khlong Luang",
+ province: "Chonburi",
+ district: "Si Racha",
branchCode: "service",
customerGroupCode: "industrial",
- customerSubGroupCode: "logistics",
+ customerSubGroupCode: "warehouse",
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",
+ leadFollowupStatusCode: "quotation_preparation",
+ opportunityStatusCode: "proposal_preparation",
+ chancePercent: 61,
+ estimatedValue: 4600000,
+ projectName: "Loading Dock Expansion",
+ projectLocation: "WHA Warehouse",
leadDescription:
- "Customer requested revision after consultant changed dock layout and bumper specification.",
- quotePlan: "rejected_then_revised",
- opportunityOutcome: "won",
+ "Dock door salesperson is still following up layout confirmation before final quotation submission.",
+ quotePlan: "sent_follow_up",
+ opportunityOutcome: "open",
multiParties: [
{
roleCode: "consultant",
@@ -882,6 +891,30 @@ function buildDocumentCode(prefix: string, number: number) {
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
}
+function buildQuotationCode(productTypeCode: string, number: number) {
+ const prefixByProductType: Record = {
+ crane: "CR",
+ dockdoor: "DK",
+ solarcell: "SOL",
+ service: "SV",
+ spare_part: "SP",
+ };
+
+ return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number);
+}
+
+function pickSalesUser(
+ users: UserContext,
+ productTypeCode: string | undefined,
+ index: number
+) {
+ if (productTypeCode && users.salesByProductType[productTypeCode]) {
+ return users.salesByProductType[productTypeCode]!;
+ }
+
+ return choose(users.sales, index);
+}
+
function seededNote(scope: string) {
return `[${SEED_TAG}:${scope}]`;
}
@@ -1027,21 +1060,48 @@ function buildUserContext(rows: UserMembershipRow[]): UserContext {
}
const first = rows[0];
- const byRole = (role: string) =>
- rows.find((row) => row.businessRole === role) ?? null;
- const salesRows = rows.filter((row) => row.businessRole === "sales");
+ const byRole = (role: string) => rows.find((row) => row.businessRole === role) ?? null;
+ const byEmail = (email: string) =>
+ rows.find((row) => row.email.toLowerCase() === email.toLowerCase()) ?? null;
+ const fallbackSalesRows = rows.filter((row) => row.businessRole === "sales");
+ const preferredSalesRows = [
+ byEmail(UAT_EMAILS.salesCrane),
+ byEmail(UAT_EMAILS.salesDockDoor),
+ ].filter((row): row is UserMembershipRow => row !== null);
+ const salesRows = preferredSalesRows.length
+ ? preferredSalesRows
+ : fallbackSalesRows.length
+ ? fallbackSalesRows
+ : [rows.find((row) => row.membershipRole === "admin") ?? first];
const admin =
+ byEmail(UAT_EMAILS.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,
+ marketing:
+ byEmail(UAT_EMAILS.marketing) ??
+ byRole("marketing") ??
+ byRole("sales_support") ??
+ admin,
+ sales: salesRows,
+ salesByProductType: {
+ crane: byEmail(UAT_EMAILS.salesCrane) ?? preferredSalesRows[0] ?? salesRows[0],
+ dockdoor:
+ byEmail(UAT_EMAILS.salesDockDoor) ??
+ preferredSalesRows.at(-1) ??
+ salesRows.at(-1) ??
+ salesRows[0],
+ },
+ salesManager: byEmail(UAT_EMAILS.salesManager) ?? byRole("sales_manager") ?? admin,
+ departmentManager:
+ byEmail(UAT_EMAILS.salesManager) ??
+ byRole("department_manager") ??
+ byRole("sales_manager") ??
+ admin,
+ topManager: byEmail(UAT_EMAILS.topManager) ?? byRole("top_manager") ?? admin,
};
}
@@ -1395,7 +1455,7 @@ function buildLeadAndOpportunityData(
}
const contact = primaryContactByCustomerId.get(customer.id) ?? null;
const createdAt = daysAgo(88 - storyIndex * 4);
- const assignedUser = choose(users.sales, storyIndex);
+ const assignedUser = pickSalesUser(users, story.productTypeCode, storyIndex);
const assignedAt = addDays(createdAt, 4);
const statusId = getOptionId(
optionMap,
@@ -2004,7 +2064,7 @@ function buildQuotationData(
const customer = storyCustomerMap.get(story.slug);
if (!opportunity || !customer) continue;
const contact = primaryContactByCustomerId.get(customer.id) ?? null;
- const salesperson = choose(users.sales, storyIndex);
+ const salesperson = pickSalesUser(users, story.productTypeCode, storyIndex);
const baseDate = addDays(opportunity.createdAt, 10);
const planRevisionCount =
story.quotePlan === "returned_then_revised"
@@ -2067,7 +2127,7 @@ function buildQuotationData(
quotations.push({
id: quotationId,
- code: buildDocumentCode("QT", quotationSequence++),
+ code: buildQuotationCode(story.productTypeCode, quotationSequence++),
opportunityId: opportunity.id,
customerId: customer.id,
contactId: contact?.id ?? null,
@@ -2405,7 +2465,10 @@ function buildQuotationData(
const quotationId = crypto.randomUUID();
quotations.push({
id: quotationId,
- code: buildDocumentCode("QT", quotationSequence++),
+ code: buildQuotationCode(
+ choose(["crane", "dockdoor", "solarcell"], quotations.length),
+ quotationSequence++
+ ),
opportunityId: null,
customerId: customer.id,
contactId: contact?.id ?? null,
diff --git a/src/db/seeds/system-organization.seed.ts b/src/db/seeds/system-organization.seed.ts
new file mode 100644
index 0000000..30322be
--- /dev/null
+++ b/src/db/seeds/system-organization.seed.ts
@@ -0,0 +1,129 @@
+import { getDefaultPermissions } from '../../lib/auth/rbac.ts';
+import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
+
+type UserRow = {
+ id: string;
+ email: string;
+};
+
+const DEFAULT_ORGANIZATION = {
+ id: deterministicId('organization:alla-demo'),
+ name: 'ALLA Demo Organization',
+ slug: 'alla-demo',
+ membershipId: deterministicId('membership:alla-demo:super-admin')
+};
+
+async function resolveSuperAdmin(sql: SqlClient): Promise {
+ const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
+
+ if (!email) {
+ throw new Error('SUPER_ADMIN_EMAIL is required before running system organization seed.');
+ }
+
+ const [user] = await sql`
+ select id, email
+ from users
+ where email = ${email}
+ limit 1
+ `;
+
+ if (!user) {
+ throw new Error('Super admin user not found. Run seed-super-admin first.');
+ }
+
+ return user;
+}
+
+async function ensureOrganization(sql: SqlClient, superAdmin: UserRow) {
+ await sql`
+ insert into organizations (
+ id,
+ name,
+ slug,
+ plan,
+ created_by
+ ) values (
+ ${DEFAULT_ORGANIZATION.id},
+ ${DEFAULT_ORGANIZATION.name},
+ ${DEFAULT_ORGANIZATION.slug},
+ ${'enterprise'},
+ ${superAdmin.id}
+ )
+ on conflict (slug) do update
+ set
+ name = excluded.name,
+ plan = excluded.plan,
+ updated_at = now()
+ `;
+
+ const membershipPermissions = getDefaultPermissions('admin', 'crm_admin');
+ const [existingMembership] = await sql<{ id: string }[]>`
+ select id
+ from memberships
+ where user_id = ${superAdmin.id}
+ and organization_id = ${DEFAULT_ORGANIZATION.id}
+ limit 1
+ `;
+
+ if (existingMembership) {
+ await sql`
+ update memberships
+ set
+ role = ${'admin'},
+ business_role = ${'crm_admin'},
+ permissions = ${sql.array(membershipPermissions)},
+ updated_at = now()
+ where id = ${existingMembership.id}
+ `;
+ } else {
+ await sql`
+ insert into memberships (
+ id,
+ user_id,
+ organization_id,
+ role,
+ business_role,
+ permissions,
+ branch_scope_ids,
+ product_type_scope_ids
+ ) values (
+ ${DEFAULT_ORGANIZATION.membershipId},
+ ${superAdmin.id},
+ ${DEFAULT_ORGANIZATION.id},
+ ${'admin'},
+ ${'crm_admin'},
+ ${sql.array(membershipPermissions)},
+ ${sql.array([])},
+ ${sql.array([])}
+ )
+ `;
+ }
+
+ await sql`
+ update users
+ set
+ active_organization_id = ${DEFAULT_ORGANIZATION.id},
+ updated_at = now()
+ where id = ${superAdmin.id}
+ `;
+}
+
+async function main() {
+ const sql = createSqlClient();
+
+ try {
+ const superAdmin = await resolveSuperAdmin(sql);
+ await ensureOrganization(sql, superAdmin);
+ console.log(`[seed:system-org] Ready ${DEFAULT_ORGANIZATION.name}`);
+ } finally {
+ await sql.end();
+ }
+}
+
+main().catch((error) => {
+ console.error(
+ '[seed:system-org] Failed:',
+ error instanceof Error ? error.message : String(error)
+ );
+ process.exitCode = 1;
+});
diff --git a/src/db/seeds/system.seed.ts b/src/db/seeds/system.seed.ts
index f5ddde0..228785e 100644
--- a/src/db/seeds/system.seed.ts
+++ b/src/db/seeds/system.seed.ts
@@ -1,5 +1,11 @@
import { spawnSync } from 'node:child_process';
+const NODE_TS_RUNTIME_ARGS = [
+ '--disable-warning=ExperimentalWarning',
+ '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
+ '--experimental-strip-types'
+];
+
function run(label: string, args: string[]) {
const result = spawnSync(process.execPath, args, {
stdio: 'inherit',
@@ -13,7 +19,12 @@ function run(label: string, args: string[]) {
function main() {
run('super admin seed', ['scripts/seed-super-admin.js']);
- run('foundation seed', ['--experimental-strip-types', 'src/db/seeds/foundation.seed.ts']);
+ run('system organization seed', [
+ ...NODE_TS_RUNTIME_ARGS,
+ 'src/db/seeds/system-organization.seed.ts'
+ ]);
+ run('foundation seed', [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/foundation.seed.ts']);
+ run('pdf template seed', [...NODE_TS_RUNTIME_ARGS, 'scripts/reseed-pdf-template-version.ts']);
}
main();
diff --git a/src/db/seeds/uat-users.seed.ts b/src/db/seeds/uat-users.seed.ts
new file mode 100644
index 0000000..6e3bb46
--- /dev/null
+++ b/src/db/seeds/uat-users.seed.ts
@@ -0,0 +1,431 @@
+import bcrypt from 'bcryptjs';
+import { getDefaultPermissions, getDefaultRoleProfiles } from '../../lib/auth/rbac.ts';
+import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
+
+type OrganizationRow = {
+ id: string;
+ name: string;
+ createdBy: string;
+};
+
+type OptionRow = {
+ id: string;
+ category: string;
+ code: string;
+};
+
+type RoleProfileRow = {
+ id: string;
+ code: string;
+};
+
+type UatRoleAssignmentSeed = {
+ roleCode: string;
+ isPrimary: boolean;
+ branchScopeMode: 'all' | 'selected' | 'inherit';
+ branchCodes?: string[];
+ productTypeScopeMode: 'all' | 'selected' | 'inherit';
+ productTypeCodes?: string[];
+};
+
+type UatUserSeed = {
+ key: string;
+ name: string;
+ email: string;
+ password: string;
+ systemRole: 'super_admin' | 'user';
+ membershipRole: 'admin' | 'user';
+ businessRole:
+ | 'marketing'
+ | 'sales'
+ | 'sales_support'
+ | 'sales_manager'
+ | 'department_manager'
+ | 'top_manager'
+ | 'crm_admin';
+ roleAssignments: UatRoleAssignmentSeed[];
+};
+
+const UAT_PASSWORD = 'UatDemo123!';
+
+const UAT_USERS: UatUserSeed[] = [
+ {
+ key: 'mk',
+ name: 'MK UAT',
+ email: 'mk.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'user',
+ businessRole: 'marketing',
+ roleAssignments: [
+ {
+ roleCode: 'marketing',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'all'
+ }
+ ]
+ },
+ {
+ key: 'sales-crane',
+ name: 'Sales Crane UAT',
+ email: 'sales.crane.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'user',
+ businessRole: 'sales',
+ roleAssignments: [
+ {
+ roleCode: 'sales',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'selected',
+ productTypeCodes: ['crane']
+ }
+ ]
+ },
+ {
+ key: 'sales-dockdoor',
+ name: 'Sales Dock Door UAT',
+ email: 'sales.dockdoor.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'user',
+ businessRole: 'sales',
+ roleAssignments: [
+ {
+ roleCode: 'sales',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'selected',
+ productTypeCodes: ['dockdoor']
+ }
+ ]
+ },
+ {
+ key: 'mgr-sales',
+ name: 'Sales Manager UAT',
+ email: 'mgr.sales.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'user',
+ businessRole: 'sales_manager',
+ roleAssignments: [
+ {
+ roleCode: 'sales_manager',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'all'
+ },
+ {
+ roleCode: 'department_manager',
+ isPrimary: false,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'all'
+ }
+ ]
+ },
+ {
+ key: 'ceo',
+ name: 'CEO UAT',
+ email: 'ceo.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'user',
+ businessRole: 'top_manager',
+ roleAssignments: [
+ {
+ roleCode: 'top_manager',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'all'
+ }
+ ]
+ },
+ {
+ key: 'admin',
+ name: 'Admin UAT',
+ email: 'admin.uat@alla.local',
+ password: UAT_PASSWORD,
+ systemRole: 'user',
+ membershipRole: 'admin',
+ businessRole: 'crm_admin',
+ roleAssignments: [
+ {
+ roleCode: 'crm_admin',
+ isPrimary: true,
+ branchScopeMode: 'all',
+ productTypeScopeMode: 'all'
+ }
+ ]
+ }
+];
+
+async function ensureRoleProfiles(
+ sql: SqlClient,
+ organization: OrganizationRow
+): Promise