task-b complate
This commit is contained in:
98
src/features/foundation/audit-log/service.ts
Normal file
98
src/features/foundation/audit-log/service.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
25
src/features/foundation/audit-log/types.ts
Normal file
25
src/features/foundation/audit-log/types.ts
Normal 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;
|
||||
}
|
||||
64
src/features/foundation/auth-context/service.ts
Normal file
64
src/features/foundation/auth-context/service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
25
src/features/foundation/auth-context/types.ts
Normal file
25
src/features/foundation/auth-context/types.ts
Normal 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;
|
||||
}
|
||||
45
src/features/foundation/branch-scope/service.ts
Normal file
45
src/features/foundation/branch-scope/service.ts
Normal 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;
|
||||
}
|
||||
6
src/features/foundation/branch-scope/types.ts
Normal file
6
src/features/foundation/branch-scope/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface FoundationBranch {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
166
src/features/foundation/document-sequence/service.ts
Normal file
166
src/features/foundation/document-sequence/service.ts
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
16
src/features/foundation/document-sequence/types.ts
Normal file
16
src/features/foundation/document-sequence/types.ts
Normal 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;
|
||||
}
|
||||
1
src/features/foundation/master-options/api/mutations.ts
Normal file
1
src/features/foundation/master-options/api/mutations.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
14
src/features/foundation/master-options/api/queries.ts
Normal file
14
src/features/foundation/master-options/api/queries.ts
Normal 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)
|
||||
});
|
||||
18
src/features/foundation/master-options/api/service.ts
Normal file
18
src/features/foundation/master-options/api/service.ts
Normal 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}` : ''}`);
|
||||
}
|
||||
32
src/features/foundation/master-options/api/types.ts
Normal file
32
src/features/foundation/master-options/api/types.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
202
src/features/foundation/master-options/service.ts
Normal file
202
src/features/foundation/master-options/service.ts
Normal 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
|
||||
});
|
||||
}
|
||||
51
src/features/foundation/master-options/types.ts
Normal file
51
src/features/foundation/master-options/types.ts
Normal 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;
|
||||
}
|
||||
16
src/features/foundation/organization-context/service.ts
Normal file
16
src/features/foundation/organization-context/service.ts
Normal 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;
|
||||
44
src/features/foundation/permission/service.ts
Normal file
44
src/features/foundation/permission/service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user