task-b complate

This commit is contained in:
phaichayon
2026-06-11 16:55:45 +07:00
parent 7a390cf0df
commit 67545d9295
135 changed files with 3295 additions and 6385 deletions

View File

@@ -0,0 +1,98 @@
import { headers } from 'next/headers';
import { trAuditLogs } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
import type { AuditLogInput, AuditLogRecord } from './types';
async function resolveAuditContext(input: AuditLogInput) {
const context = await getCurrentUserContext();
const organizationId = input.organizationId ?? context?.activeOrganization?.id ?? null;
const userId = input.userId ?? context?.user.id ?? null;
if (!organizationId) {
throw new Error('organizationId is required for audit logging');
}
if (!userId) {
throw new Error('userId is required for audit logging');
}
return {
organizationId,
userId
};
}
async function resolveRequestId(inputRequestId?: string | null) {
if (inputRequestId) {
return inputRequestId;
}
try {
const requestHeaders = await headers();
return requestHeaders.get('x-request-id');
} catch {
return null;
}
}
function mapAuditRecord(row: typeof trAuditLogs.$inferSelect): AuditLogRecord {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId,
userId: row.userId,
entityType: row.entityType,
entityId: row.entityId,
action: row.action,
beforeData: row.beforeData,
afterData: row.afterData,
requestId: row.requestId,
createdAt: row.createdAt.toISOString()
};
}
export async function auditAction(input: AuditLogInput) {
const { organizationId, userId } = await resolveAuditContext(input);
const requestId = await resolveRequestId(input.requestId);
const [created] = await db
.insert(trAuditLogs)
.values({
id: crypto.randomUUID(),
organizationId,
branchId: input.branchId ?? null,
userId,
entityType: input.entityType,
entityId: input.entityId,
action: input.action,
beforeData:
input.beforeData === undefined ? null : JSON.parse(JSON.stringify(input.beforeData)),
afterData: input.afterData === undefined ? null : JSON.parse(JSON.stringify(input.afterData)),
requestId
})
.returning();
return mapAuditRecord(created);
}
export async function auditCreate(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'create'
});
}
export async function auditUpdate(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'update'
});
}
export async function auditDelete(input: Omit<AuditLogInput, 'action'>) {
return auditAction({
...input,
action: 'delete'
});
}

View File

@@ -0,0 +1,25 @@
export interface AuditLogInput {
organizationId?: string;
branchId?: string | null;
userId?: string;
entityType: string;
entityId: string;
action: string;
beforeData?: unknown;
afterData?: unknown;
requestId?: string | null;
}
export interface AuditLogRecord {
id: string;
organizationId: string;
branchId: string | null;
userId: string;
entityType: string;
entityId: string;
action: string;
beforeData: unknown;
afterData: unknown;
requestId: string | null;
createdAt: string;
}

View File

@@ -0,0 +1,64 @@
import { auth } from '@/auth';
import type { CurrentOrganizationContext, CurrentUserContext } from './types';
import { requireSession } from '@/lib/auth/session';
export async function getCurrentUser() {
const session = await auth();
return session?.user ?? null;
}
export async function requireCurrentUser() {
const session = await requireSession();
return session.user;
}
function getActiveOrganizationFromUser(
user: NonNullable<Awaited<ReturnType<typeof getCurrentUser>>>
): CurrentOrganizationContext | null {
if (!user.activeOrganizationId) {
return null;
}
const organization = user.organizations.find((item) => item.id === user.activeOrganizationId);
if (!organization) {
return null;
}
return {
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl
};
}
export async function getCurrentUserContext(): Promise<CurrentUserContext | null> {
const user = await getCurrentUser();
if (!user) {
return null;
}
const activeOrganization = getActiveOrganizationFromUser(user);
const membership = activeOrganization
? {
userId: user.id,
organizationId: activeOrganization.id,
role: user.activeMembershipRole ?? 'user',
businessRole: user.activeBusinessRole ?? 'viewer',
permissions: user.activePermissions
}
: null;
return {
user,
activeOrganization,
membership,
permissions: user.activePermissions,
businessRole: user.activeBusinessRole ?? null
};
}

View File

@@ -0,0 +1,25 @@
import type { Session } from 'next-auth';
export interface CurrentUserMembershipContext {
userId: string;
organizationId: string;
role: string;
businessRole: string;
permissions: string[];
}
export interface CurrentOrganizationContext {
id: string;
name: string;
slug: string;
plan: string;
imageUrl: string | null;
}
export interface CurrentUserContext {
user: Session['user'];
activeOrganization: CurrentOrganizationContext | null;
membership: CurrentUserMembershipContext | null;
permissions: string[];
businessRole: string | null;
}

View File

@@ -0,0 +1,45 @@
import { AuthError } from '@/lib/auth/session';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import type { FoundationBranch } from './types';
function mapBranchOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
): FoundationBranch {
return {
id: option.id,
code: option.code,
name: option.label,
value: option.value
};
}
export async function getUserBranches(): Promise<FoundationBranch[]> {
const options = await getActiveOptionsByCategory('crm_branch');
return options.map(mapBranchOption);
}
export async function getActiveBranch(): Promise<FoundationBranch | null> {
const branches = await getUserBranches();
if (branches.length === 1) {
return branches[0];
}
return null;
}
export async function validateBranchAccess(branchId?: string | null) {
if (!branchId) {
return getActiveBranch();
}
const branches = await getUserBranches();
const branch = branches.find((item) => item.id === branchId);
if (!branch) {
throw new AuthError('Branch access denied', 403);
}
return branch;
}

View File

@@ -0,0 +1,6 @@
export interface FoundationBranch {
id: string;
code: string;
name: string;
value: string | null;
}

View File

@@ -0,0 +1,166 @@
import { and, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type { DocumentSequenceInput, DocumentSequenceResult } from './types';
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS',
contact: 'CON',
enquiry: 'ENQ',
quotation: 'QT',
approval: 'APV'
};
function getCurrentPeriod(date = new Date()) {
const year = String(date.getFullYear()).slice(-2);
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${year}${month}`;
}
function buildDocumentCode(
prefix: string,
period: string,
nextNumber: number,
paddingLength: number
) {
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
}
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
}
return organization.id;
}
async function ensureSequence(
organizationId: string,
documentType: string,
period: string,
branchId: string
) {
const existing = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
)
});
if (existing) {
return existing;
}
const prefix = DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
const [created] = await db
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
branchId,
documentType,
period,
prefix,
currentNumber: 0,
paddingLength: 3,
isActive: true
})
.returning();
return created;
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
return {
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
documentType: row.documentType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber,
period: row.period,
prefix: row.prefix
};
}
export async function previewNextDocumentCode(
input: DocumentSequenceInput
): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? '';
const sequence = await ensureSequence(organizationId, input.documentType, period, branchId);
return toSequenceResult(sequence);
}
export async function generateNextDocumentCode(
input: DocumentSequenceInput
): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? '';
await ensureSequence(organizationId, input.documentType, period, branchId);
return db.transaction(async (tx) => {
await tx.execute(sql`
select id
from document_sequences
where organization_id = ${organizationId}
and document_type = ${input.documentType}
and period = ${period}
and branch_id = ${branchId}
for update
`);
const sequence = await tx.query.documentSequences.findFirst({
where: and(
eq(documentSequences.organizationId, organizationId),
eq(documentSequences.documentType, input.documentType),
eq(documentSequences.period, period),
eq(documentSequences.branchId, branchId)
)
});
if (!sequence) {
throw new Error('Document sequence not found');
}
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sequence.currentNumber + 1,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
.returning();
return {
code: buildDocumentCode(
updated.prefix,
updated.period,
updated.currentNumber,
updated.paddingLength
),
documentType: updated.documentType,
branchId: updated.branchId || null,
currentNumber: updated.currentNumber,
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
};
});
}

View File

@@ -0,0 +1,16 @@
export interface DocumentSequenceInput {
documentType: string;
branchId?: string | null;
organizationId?: string;
period?: string;
}
export interface DocumentSequenceResult {
code: string;
documentType: string;
branchId: string | null;
currentNumber: number;
nextNumber: number;
period: string;
prefix: string;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,14 @@
import { queryOptions } from '@tanstack/react-query';
import { getMasterOptions } from './service';
import type { MasterOptionsFilters } from './types';
export const masterOptionKeys = {
all: ['foundation', 'master-options'] as const,
list: (filters: MasterOptionsFilters) => [...masterOptionKeys.all, 'list', filters] as const
};
export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) =>
queryOptions({
queryKey: masterOptionKeys.list(filters),
queryFn: () => getMasterOptions(filters)
});

View File

@@ -0,0 +1,18 @@
import { apiClient } from '@/lib/api-client';
import type { MasterOptionsFilters, MasterOptionsResponse } from './types';
export async function getMasterOptions(
filters: MasterOptionsFilters
): Promise<MasterOptionsResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.category) searchParams.set('category', filters.category);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<MasterOptionsResponse>(`/foundation/master-options${query ? `?${query}` : ''}`);
}

View File

@@ -0,0 +1,32 @@
export interface MasterOptionItem {
id: string;
organization_id: string;
category: string;
code: string;
label: string;
value: string | null;
parent_id: string | null;
parent_label: string | null;
sort_order: number;
is_active: boolean;
deleted_at: string | null;
updated_at: string;
}
export interface MasterOptionsFilters {
page?: number;
limit?: number;
search?: string;
category?: string;
sort?: string;
}
export interface MasterOptionsResponse {
success: boolean;
time: string;
message: string;
total_items: number;
offset: number;
limit: number;
items: MasterOptionItem[];
}

View File

@@ -0,0 +1,60 @@
'use client';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Badge } from '@/components/ui/badge';
import { CRM_MASTER_OPTION_CATEGORIES } from '@/features/foundation/master-options/types';
import type { MasterOptionItem } from '../api/types';
const categoryOptions = CRM_MASTER_OPTION_CATEGORIES.map((category) => ({
label: category,
value: category
}));
export const columns: ColumnDef<MasterOptionItem>[] = [
{
id: 'category',
accessorKey: 'category',
header: ({ column }) => <DataTableColumnHeader column={column} title='Category' />,
meta: {
label: 'Category',
variant: 'select',
options: categoryOptions
},
enableColumnFilter: true
},
{
id: 'code',
accessorKey: 'code',
header: ({ column }) => <DataTableColumnHeader column={column} title='Code' />
},
{
id: 'name',
accessorKey: 'label',
header: ({ column }) => <DataTableColumnHeader column={column} title='Label' />,
meta: {
label: 'Label',
variant: 'text',
placeholder: 'Search options...'
},
enableColumnFilter: true
},
{
id: 'parent_label',
accessorKey: 'parent_label',
header: ({ column }) => <DataTableColumnHeader column={column} title='Parent' />,
cell: ({ row }) => row.original.parent_label ?? '-'
},
{
id: 'sort_order',
accessorKey: 'sort_order',
header: ({ column }) => <DataTableColumnHeader column={column} title='Sort' />
},
{
id: 'is_active',
accessorKey: 'is_active',
header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
cell: ({ row }) =>
row.original.is_active ? <Badge>Active</Badge> : <Badge variant='outline'>Inactive</Badge>
}
];

View File

@@ -0,0 +1,31 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { masterOptionsQueryOptions } from '../api/queries';
import { MasterOptionsTable } from './master-options-table';
export default function MasterOptionsListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const category = searchParamsCache.get('category');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(category && { category }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(masterOptionsQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsTable />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import { masterOptionsQueryOptions } from '../api/queries';
import { columns } from './columns';
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
export function MasterOptionsTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
category: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.category && { category: params.category }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(masterOptionsQueryOptions(filters));
const pageCount = Math.ceil(data.total_items / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
shallow: true,
debounceMs: 500
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}

View File

@@ -0,0 +1,202 @@
import { and, asc, count, desc, eq, inArray, ilike, isNull, or, type SQL } from 'drizzle-orm';
import { msOptions } from '@/db/schema';
import { db } from '@/lib/db';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type {
MasterOptionListFilters,
MasterOptionListResult,
MasterOptionNode,
MasterOptionRecord
} from './types';
async function resolveOrganizationId(organizationId?: string) {
if (organizationId) {
return organizationId;
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
}
return organization.id;
}
function parseSort(sort?: string) {
if (!sort) {
return desc(msOptions.updatedAt);
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
const [rule] = parsed;
if (!rule) {
return desc(msOptions.updatedAt);
}
const sortMap = {
category: msOptions.category,
code: msOptions.code,
name: msOptions.label,
label: msOptions.label,
sortOrder: msOptions.sortOrder,
updatedAt: msOptions.updatedAt
} as const;
const column = sortMap[rule.id as keyof typeof sortMap];
if (!column) {
return desc(msOptions.updatedAt);
}
return rule.desc ? desc(column) : asc(column);
} catch {
return desc(msOptions.updatedAt);
}
}
function buildFilters(organizationId: string, filters: MasterOptionListFilters): SQL[] {
return [
eq(msOptions.organizationId, organizationId),
...(filters.category ? [eq(msOptions.category, filters.category)] : []),
...(filters.activeOnly ? [eq(msOptions.isActive, true)] : []),
...(!filters.includeDeleted ? [isNull(msOptions.deletedAt)] : []),
...(filters.search
? [
or(
ilike(msOptions.label, `%${filters.search}%`),
ilike(msOptions.code, `%${filters.search}%`),
ilike(msOptions.category, `%${filters.search}%`)
)!
]
: [])
];
}
function mapMasterOption(
row: typeof msOptions.$inferSelect,
parentLabel: string | null
): MasterOptionRecord {
return {
id: row.id,
organizationId: row.organizationId,
category: row.category,
code: row.code,
label: row.label,
value: row.value,
parentId: row.parentId,
parentLabel,
sortOrder: row.sortOrder,
isActive: row.isActive,
metadata:
row.metadata && typeof row.metadata === 'object'
? (row.metadata as Record<string, unknown>)
: null,
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function listMasterOptions(
filters: MasterOptionListFilters = {}
): Promise<MasterOptionListResult> {
const organizationId = await resolveOrganizationId(filters.organizationId);
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(msOptions).where(where);
const rows = await db
.select()
.from(msOptions)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
.offset(offset);
const parentIds = [
...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))
];
const parentRows = parentIds.length
? await db
.select()
.from(msOptions)
.where(and(eq(msOptions.organizationId, organizationId), inArray(msOptions.id, parentIds)))
: [];
const parentMap = new Map(parentRows.map((row) => [row.id, row.label]));
return {
items: rows.map((row) => mapMasterOption(row, parentMap.get(row.parentId ?? '') ?? null)),
totalItems: totalResult?.value ?? 0
};
}
export async function getOptionsByCategory(
category: string,
options?: {
organizationId?: string;
includeDeleted?: boolean;
activeOnly?: boolean;
}
): Promise<MasterOptionNode[]> {
const organizationId = await resolveOrganizationId(options?.organizationId);
const whereFilters = buildFilters(organizationId, {
category,
activeOnly: options?.activeOnly,
includeDeleted: options?.includeDeleted,
limit: Number.MAX_SAFE_INTEGER,
page: 1
});
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const rows = await db
.select()
.from(msOptions)
.where(where)
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label));
const parentMap = new Map(rows.map((row) => [row.id, row]));
const itemMap = new Map<string, MasterOptionNode>();
for (const row of rows) {
const parent = row.parentId ? (parentMap.get(row.parentId) ?? null) : null;
itemMap.set(
row.id,
Object.assign(mapMasterOption(row, parent?.label ?? null), {
children: []
})
);
}
const roots: MasterOptionNode[] = [];
for (const item of itemMap.values()) {
if (item.parentId) {
const parent = itemMap.get(item.parentId);
if (parent) {
parent.children.push(item);
continue;
}
}
roots.push(item);
}
return roots;
}
export async function getActiveOptionsByCategory(
category: string,
options?: { organizationId?: string }
) {
return getOptionsByCategory(category, {
organizationId: options?.organizationId,
activeOnly: true
});
}

View File

@@ -0,0 +1,51 @@
export const CRM_MASTER_OPTION_CATEGORIES = [
'crm_branch',
'crm_customer_status',
'crm_customer_type',
'crm_enquiry_status',
'crm_quotation_status',
'crm_product_type',
'crm_currency',
'crm_payment_term',
'crm_priority',
'crm_lead_channel'
] as const;
export type CrmMasterOptionCategory = (typeof CRM_MASTER_OPTION_CATEGORIES)[number];
export interface MasterOptionRecord {
id: string;
organizationId: string;
category: string;
code: string;
label: string;
value: string | null;
parentId: string | null;
parentLabel: string | null;
sortOrder: number;
isActive: boolean;
metadata: Record<string, unknown> | null;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface MasterOptionNode extends MasterOptionRecord {
children: MasterOptionNode[];
}
export interface MasterOptionListFilters {
organizationId?: string;
category?: string;
search?: string;
page?: number;
limit?: number;
sort?: string;
activeOnly?: boolean;
includeDeleted?: boolean;
}
export interface MasterOptionListResult {
items: MasterOptionRecord[];
totalItems: number;
}

View File

@@ -0,0 +1,16 @@
import { requireOrganizationAccess as requireOrganizationAccessFromSession } from '@/lib/auth/session';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
export async function getCurrentOrganization() {
const context = await getCurrentUserContext();
return context?.activeOrganization ?? null;
}
export async function getActiveOrganizationId() {
const context = await getCurrentUserContext();
return context?.activeOrganization?.id ?? null;
}
export const requireOrganizationAccess = requireOrganizationAccessFromSession;

View File

@@ -0,0 +1,44 @@
import { AuthError } from '@/lib/auth/session';
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
import { requireOrganizationAccess } from '@/features/foundation/organization-context/service';
export async function hasPermission(permission: string) {
const context = await getCurrentUserContext();
if (!context) {
return false;
}
if (context.user.systemRole === 'super_admin') {
return true;
}
return context.permissions.includes(permission);
}
export async function requirePermission(permission: string) {
try {
await requireOrganizationAccess({ permission });
return true;
} catch (error) {
if (error instanceof AuthError) {
throw error;
}
throw new AuthError('Forbidden', 403);
}
}
export async function hasBusinessRole(role: string) {
const context = await getCurrentUserContext();
if (!context) {
return false;
}
if (context.user.systemRole === 'super_admin') {
return true;
}
return context.businessRole === role;
}