task-fix-display2

This commit is contained in:
phaichayon
2026-06-29 08:24:10 +07:00
parent 071293b60f
commit 2540d52670
9 changed files with 532 additions and 163 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

228
plans/task-fix-display2.md Normal file
View File

@@ -0,0 +1,228 @@
# Task Fix: Document Sequence Branch Display Name
## Objective
Fix `/dashboard/crm/settings/document-sequences` so Branch displays as a readable name/code instead of raw UUID.
Current issue:
```txt
1b960bfc-91de-4b69-a389-f060ef21bed2
```
should display as:
```txt
Branch Name
```
or:
```txt
Branch Code - Branch Name
```
---
## Problem
Document Sequence records store:
```txt
branchId
```
but the settings UI currently displays the raw ID directly.
This breaks admin usability and is inconsistent with the display-name cleanup direction.
---
## Mandatory Review
Review:
```txt
src/features/foundation/document-sequence/**
src/app/dashboard/crm/settings/document-sequences/**
src/db/schema.ts
src/features/foundation/display/**
src/features/foundation/master-options/**
```
Search:
```bash
rg "branchId|branch_id|document-sequences|DocumentSequence" src/features/foundation src/app/dashboard/crm/settings/document-sequences
```
---
## Scope 1: API / DTO Display Fields
Update document sequence read model to include:
```ts
branchId?: string | null;
branchCode?: string | null;
branchName?: string | null;
branchDisplayName?: string | null;
```
Display rule:
```txt
branchId = null
→ All Branches
branchCode + branchName
→ {branchCode} - {branchName}
branchName only
→ {branchName}
unknown branch
→ Unknown Branch ({branchId})
```
Do not remove `branchId`; keep it for update/generate logic.
---
## Scope 2: Resolve Branch Display Server-Side
In document sequence service/list API, resolve branch display before returning response.
Preferred:
```txt
Use shared branch display resolver if already created.
```
Fallback:
* If branch is table-backed, join branch table.
* If branch is `ms_options`, resolve from the branch option category.
* If unknown, return fallback display string.
Avoid N+1 queries.
Batch resolve all branch IDs in the list.
---
## Scope 3: UI Rendering Fix
Update document sequence table/cards.
Replace:
```tsx
{row.branchId}
```
with:
```tsx
{row.branchDisplayName ?? 'All Branches'}
```
or:
```tsx
<BranchDisplay value={row.branchDisplayName} />
```
Rules:
* never show raw UUID in normal UI
* raw ID may be visible only in debug/dev details if explicitly labelled
* use muted text for fallback unknown branch
---
## Scope 4: Form Select Display
If create/edit form has branch select:
* show readable branch names in dropdown
* value remains `branchId`
* include option:
```txt
All Branches
```
for nullable branch
Dropdown labels:
```txt
ALL - All Branches
BKK - Bangkok
RYG - Rayong
```
---
## Scope 5: Seed Check
Verify seed creates branch data that can be resolved.
Check whether branch source is:
```txt
branch table
```
or:
```txt
ms_options category
```
If document sequence references a branch ID that seed does not create, fix seed.
---
## Scope 6: Verification
Run:
```bash
npm exec tsc --noEmit
npm run build
```
Manual verification:
```txt
Open /dashboard/crm/settings/document-sequences
No raw UUID is shown for branch
All Branches displays correctly for null branchId
Known branch displays readable name/code
Unknown branch fallback is readable
Create/edit branch dropdown shows names, not IDs
Saving still persists branchId correctly
Document number generation still works
```
---
## Definition of Done
Task is complete when:
* Document Sequence list does not show raw branch UUID
* API returns branch display fields
* UI uses branch display fields
* branch select uses readable labels
* document sequence generation behavior remains unchanged
Result:
```txt
Document Sequence Branch Display = Fixed
Admin UX = Improved
Raw Branch UUID Leakage = Removed
```

View File

@@ -1,6 +1,7 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { DocumentSequenceSettings } from '@/features/foundation/document-sequence/components/document-sequence-settings';
import { documentSequencesQueryOptions } from '@/features/foundation/document-sequence/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
@@ -19,6 +20,11 @@ export default async function DocumentSequencesRoute() {
void queryClient.prefetchQuery(documentSequencesQueryOptions());
}
const branches =
canRead && session?.user?.activeOrganizationId
? await getUserBranches(session.user.activeOrganizationId)
: [];
return (
<PageContainer
pageTitle='Document Sequences'
@@ -32,7 +38,7 @@ export default async function DocumentSequencesRoute() {
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentSequenceSettings />
<DocumentSequenceSettings branches={branches} />
</HydrationBoundary>
) : null}
</PageContainer>

View File

@@ -1,5 +1,6 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { MasterOptionsCreateAction } from '@/features/foundation/master-options/components/master-options-create-action';
import MasterOptionsListingPage from '@/features/foundation/master-options/components/master-options-listing';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
@@ -12,11 +13,16 @@ type PageProps = {
export default async function MasterOptionsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canManageOptions =
const canReadOptions =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmMasterOptionRead)));
const canCreateOptions =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmMasterOptionCreate)));
searchParamsCache.parse(searchParams);
@@ -24,12 +30,13 @@ export default async function MasterOptionsRoute(props: PageProps) {
<PageContainer
pageTitle='Master Options'
pageDescription='Organization-scoped CRM option registry for statuses, product types, payment terms, currency, and branch abstractions.'
access={canManageOptions}
access={canReadOptions}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to master option management.
</div>
}
pageHeaderAction={canCreateOptions ? <MasterOptionsCreateAction /> : null}
>
<MasterOptionsListingPage />
</PageContainer>

View File

@@ -15,8 +15,16 @@ import {
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { formatDateTime } from '@/lib/date-format';
import type { FoundationBranch } from '@/features/foundation/branch-scope/types';
import {
createDocumentSequenceMutation,
deleteDocumentSequenceMutation,
@@ -24,7 +32,11 @@ import {
updateDocumentSequenceMutation
} from '../mutations';
import { documentSequencesQueryOptions } from '../queries';
import type { DocumentSequenceListItem, DocumentSequenceMutationPayload } from '../types';
import type {
DocumentSequenceBranchOption,
DocumentSequenceListItem,
DocumentSequenceMutationPayload
} from '../types';
type SequenceState = {
documentType: string;
@@ -36,12 +48,21 @@ type SequenceState = {
isActive: boolean;
};
function toBranchOptions(branches: FoundationBranch[]): DocumentSequenceBranchOption[] {
return branches.map((branch) => ({
id: branch.id,
code: branch.code,
name: branch.name,
displayName: `${branch.code.toUpperCase()} - ${branch.name}`
}));
}
function toState(sequence?: DocumentSequenceListItem): SequenceState {
return {
documentType: sequence?.documentType ?? 'quotation',
prefix: sequence?.prefix ?? 'QT',
period: sequence?.period ?? '',
branchId: sequence?.branchId ?? '',
branchId: sequence?.branchId ?? '__all__',
currentNumber: String(sequence?.currentNumber ?? 0),
paddingLength: String(sequence?.paddingLength ?? 3),
isActive: sequence?.isActive ?? true
@@ -53,7 +74,7 @@ function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
documentType: state.documentType,
prefix: state.prefix,
period: state.period,
branchId: state.branchId || null,
branchId: state.branchId === '__all__' ? null : state.branchId,
currentNumber: Number(state.currentNumber || 0),
paddingLength: Number(state.paddingLength || 3),
isActive: state.isActive
@@ -63,11 +84,13 @@ function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
function SequenceDialog({
open,
onOpenChange,
sequence
sequence,
branches
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sequence?: DocumentSequenceListItem;
branches: DocumentSequenceBranchOption[];
}) {
const [state, setState] = React.useState<SequenceState>(toState(sequence));
const createMutation = useMutation({
@@ -76,7 +99,9 @@ function SequenceDialog({
toast.success('Sequence created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Create failed');
}
});
const updateMutation = useMutation({
...updateDocumentSequenceMutation,
@@ -84,7 +109,9 @@ function SequenceDialog({
toast.success('Sequence updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Update failed');
}
});
React.useEffect(() => {
@@ -120,38 +147,57 @@ function SequenceDialog({
<Input
placeholder='Document type'
value={state.documentType}
onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))}
onChange={(event) =>
setState((current) => ({ ...current, documentType: event.target.value }))
}
/>
<div className='grid gap-4 md:grid-cols-2'>
<Input
placeholder='Prefix'
value={state.prefix}
onChange={(e) => setState((current) => ({ ...current, prefix: e.target.value }))}
onChange={(event) =>
setState((current) => ({ ...current, prefix: event.target.value }))
}
/>
<Input
placeholder='Period'
value={state.period}
onChange={(e) => setState((current) => ({ ...current, period: e.target.value }))}
onChange={(event) =>
setState((current) => ({ ...current, period: event.target.value }))
}
/>
</div>
<div className='grid gap-4 md:grid-cols-3'>
<Input
placeholder='Branch ID (optional)'
<Select
value={state.branchId}
onChange={(e) => setState((current) => ({ ...current, branchId: e.target.value }))}
/>
onValueChange={(value) =>
setState((current) => ({ ...current, branchId: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Select branch scope' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>ALL - All Branches</SelectItem>
{branches.map((branch) => (
<SelectItem key={branch.id} value={branch.id}>
{branch.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder='Current number'
value={state.currentNumber}
onChange={(e) =>
setState((current) => ({ ...current, currentNumber: e.target.value }))
onChange={(event) =>
setState((current) => ({ ...current, currentNumber: event.target.value }))
}
/>
<Input
placeholder='Padding length'
value={state.paddingLength}
onChange={(e) =>
setState((current) => ({ ...current, paddingLength: e.target.value }))
onChange={(event) =>
setState((current) => ({ ...current, paddingLength: event.target.value }))
}
/>
</div>
@@ -183,7 +229,7 @@ function SequenceDialog({
);
}
export function DocumentSequenceSettings() {
export function DocumentSequenceSettings({ branches }: { branches: FoundationBranch[] }) {
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
const [dialogState, setDialogState] = React.useState<{
open: boolean;
@@ -191,15 +237,25 @@ export function DocumentSequenceSettings() {
}>({
open: false
});
const branchOptions = React.useMemo(() => toBranchOptions(branches), [branches]);
const resetMutation = useMutation({
...resetDocumentSequenceMutation,
onSuccess: () => toast.success('Sequence reset'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Reset failed')
onSuccess: () => {
toast.success('Sequence reset');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Reset failed');
}
});
const deleteMutation = useMutation({
...deleteDocumentSequenceMutation,
onSuccess: () => toast.success('Sequence deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
onSuccess: () => {
toast.success('Sequence deleted');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Delete failed');
}
});
return (
@@ -229,8 +285,16 @@ export function DocumentSequenceSettings() {
<div>
<div className='text-xl font-semibold'>{sequence.documentType}</div>
<div className='text-muted-foreground text-sm'>
{sequence.prefix}
{sequence.period} branch {sequence.branchId || 'default'}
{sequence.prefix} {sequence.period} {'\u2022'}{' '}
<span
className={
sequence.branchName || !sequence.branchId
? undefined
: 'text-muted-foreground italic'
}
>
{sequence.branchDisplayName ?? 'All Branches'}
</span>
</div>
</div>
<div className='flex gap-2'>
@@ -240,7 +304,14 @@ export function DocumentSequenceSettings() {
<Badge variant='outline'>Next {sequence.nextPreview}</Badge>
</div>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-4'>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Branch</div>
<div className='mt-1 text-sm font-medium'>
{sequence.branchDisplayName ?? 'All Branches'}
</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Current Number</div>
<div className='mt-1 text-sm font-medium'>{sequence.currentNumber}</div>
@@ -251,17 +322,15 @@ export function DocumentSequenceSettings() {
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Updated At</div>
<div className='mt-1 text-sm font-medium'>
{formatDateTime(sequence.updatedAt)}
</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Preview</div>
<div className='mt-1 text-sm font-medium'>{sequence.nextPreview}</div>
<div className='mt-1 text-sm font-medium'>{formatDateTime(sequence.updatedAt)}</div>
</div>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button variant='outline' onClick={() => setDialogState({ open: true, sequence })}>
<Button
variant='outline'
onClick={() => setDialogState({ open: true, sequence })}
>
Edit Sequence
</Button>
<Button
@@ -292,6 +361,7 @@ export function DocumentSequenceSettings() {
open={dialogState.open}
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
sequence={dialogState.sequence}
branches={branchOptions}
/>
</div>
);

View File

@@ -1,8 +1,9 @@
import { and, asc, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema';
import { resolveBranchDisplays } from '@/features/foundation/display/server/display-resolver';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type {
DocumentSequenceInput,
DocumentSequenceListItem,
@@ -42,32 +43,24 @@ function normalizeBranchId(branchId?: string | null) {
return branchId?.trim() || '';
}
function mapSequenceRecord(row: typeof documentSequences.$inferSelect): DocumentSequenceRecord {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId || null,
documentType: row.documentType,
prefix: row.prefix,
period: row.period,
currentNumber: row.currentNumber,
paddingLength: row.paddingLength,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
function formatBranchDisplayName(input: {
branchId: string | null;
branchCode?: string | null;
branchName?: string | null;
}) {
if (!input.branchId) {
return 'All Branches';
}
function mapSequenceListItem(row: typeof documentSequences.$inferSelect): DocumentSequenceListItem {
return {
...mapSequenceRecord(row),
nextPreview: buildDocumentCode(
row.prefix,
row.period,
row.currentNumber + 1,
row.paddingLength
)
};
if (input.branchCode && input.branchName) {
return `${input.branchCode} - ${input.branchName}`;
}
if (input.branchName) {
return input.branchName;
}
return `Unknown Branch (${input.branchId})`;
}
async function resolveOrganizationId(organizationId?: string) {
@@ -76,31 +69,43 @@ async function resolveOrganizationId(organizationId?: string) {
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
throw new AuthError('Active organization required', 400);
}
return organization.id;
}
async function assertSequence(id: string, organizationId: string) {
const [sequence] = await db
.select()
.from(documentSequences)
.where(
and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
)
.limit(1);
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
if (!sequence) {
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
};
}
async function assertSequence(id: string, organizationId: string) {
const row = await db.query.documentSequences.findFirst({
where: and(
eq(documentSequences.id, id),
eq(documentSequences.organizationId, organizationId)
)
});
if (!row) {
throw new AuthError('Document sequence not found', 404);
}
return sequence;
return row;
}
async function ensureSequence(
async function ensureSequenceRow(
organizationId: string,
documentType: string,
period: string,
@@ -119,7 +124,8 @@ async function ensureSequence(
return existing;
}
const prefix = DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
const prefix =
DEFAULT_DOCUMENT_PREFIXES[documentType] ?? documentType.slice(0, 3).toUpperCase();
const [created] = await db
.insert(documentSequences)
@@ -139,21 +145,45 @@ async function ensureSequence(
return created;
}
function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentSequenceResult {
const nextNumber = row.currentNumber + 1;
async function mapSequenceRecords(
organizationId: string,
rows: Array<typeof documentSequences.$inferSelect>
) {
const branchDisplayMap = await resolveBranchDisplays({
organizationId,
branchIds: rows.map((row) => row.branchId || null)
});
return rows.map((row) => {
const branchId = row.branchId || null;
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
return {
code: buildDocumentCode(row.prefix, row.period, nextNumber, row.paddingLength),
id: row.id,
organizationId: row.organizationId,
branchId,
branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null,
branchDisplayName: formatBranchDisplayName({
branchId,
branchCode: branchDisplay?.code ?? null,
branchName: branchDisplay?.name ?? null
}),
documentType: row.documentType,
branchId: row.branchId || null,
currentNumber: row.currentNumber,
nextNumber,
prefix: row.prefix,
period: row.period,
prefix: row.prefix
};
currentNumber: row.currentNumber,
paddingLength: row.paddingLength,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
} satisfies DocumentSequenceRecord;
});
}
export async function listDocumentSequences(organizationId: string): Promise<DocumentSequenceListItem[]> {
export async function listDocumentSequences(
organizationId: string
): Promise<DocumentSequenceListItem[]> {
const rows = await db
.select()
.from(documentSequences)
@@ -164,12 +194,27 @@ export async function listDocumentSequences(organizationId: string): Promise<Doc
asc(documentSequences.branchId)
);
return rows.map(mapSequenceListItem);
const records = await mapSequenceRecords(organizationId, rows);
return records.map((record) => ({
...record,
nextPreview: buildDocumentCode(
record.prefix,
record.period,
record.currentNumber + 1,
record.paddingLength
)
}));
}
export async function getDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
export async function getDocumentSequence(
id: string,
organizationId: string
): Promise<DocumentSequenceRecord> {
const row = await assertSequence(id, organizationId);
return mapSequenceRecord(row);
const [record] = await mapSequenceRecords(organizationId, [row]);
return record;
}
export async function createDocumentSequence(
@@ -193,7 +238,8 @@ export async function createDocumentSequence(
})
.returning();
return mapSequenceRecord(created);
const [record] = await mapSequenceRecords(organizationId, [created]);
return record;
}
export async function updateDocumentSequence(
@@ -209,7 +255,9 @@ export async function updateDocumentSequence(
prefix: payload.prefix?.trim() ?? current.prefix,
period: payload.period?.trim() ?? current.period,
branchId:
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId),
payload.branchId === undefined
? current.branchId
: normalizeBranchId(payload.branchId),
currentNumber: payload.currentNumber ?? current.currentNumber,
paddingLength: payload.paddingLength ?? current.paddingLength,
isActive: payload.isActive ?? current.isActive,
@@ -218,7 +266,8 @@ export async function updateDocumentSequence(
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(updated);
const [record] = await mapSequenceRecords(organizationId, [updated]);
return record;
}
export async function resetDocumentSequence(
@@ -236,75 +285,40 @@ export async function resetDocumentSequence(
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(updated);
const [record] = await mapSequenceRecords(organizationId, [updated]);
return record;
}
export async function deleteDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
await assertSequence(id, organizationId);
const [deleted] = await db
.delete(documentSequences)
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(deleted);
}
export async function previewDocumentSequenceById(
id: string,
organizationId: string
): Promise<DocumentSequenceResult> {
export async function deleteDocumentSequence(id: string, organizationId: string) {
const sequence = await assertSequence(id, organizationId);
return toSequenceResult(sequence);
await db.delete(documentSequences).where(eq(documentSequences.id, id));
const [record] = await mapSequenceRecords(organizationId, [sequence]);
return record;
}
export async function previewNextDocumentCode(
input: DocumentSequenceInput
): Promise<DocumentSequenceResult> {
export async function previewDocumentSequenceById(id: string, organizationId: string) {
const row = await assertSequence(id, organizationId);
return toSequenceResult(row);
}
export async function generateNextDocumentCode(input: DocumentSequenceInput) {
const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod();
const branchId = normalizeBranchId(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 = normalizeBranchId(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 sequence = await ensureSequenceRow(
organizationId,
input.documentType.trim(),
period,
branchId
);
const [updated] = await tx
.update(documentSequences)
.set({
currentNumber: sequence.currentNumber + 1,
currentNumber: sql`${documentSequences.currentNumber} + 1`,
updatedAt: new Date()
})
.where(eq(documentSequences.id, sequence.id))
@@ -323,7 +337,6 @@ export async function generateNextDocumentCode(
nextNumber: updated.currentNumber + 1,
period: updated.period,
prefix: updated.prefix
};
} satisfies DocumentSequenceResult;
});
}

View File

@@ -9,6 +9,9 @@ export interface DocumentSequenceRecord {
id: string;
organizationId: string;
branchId: string | null;
branchCode: string | null;
branchName: string | null;
branchDisplayName: string | null;
documentType: string;
prefix: string;
period: string;
@@ -33,6 +36,13 @@ export interface DocumentSequenceListItem extends DocumentSequenceRecord {
nextPreview: string;
}
export interface DocumentSequenceBranchOption {
id: string;
code: string;
name: string;
displayName: string;
}
export interface DocumentSequenceListResponse {
success: boolean;
time: string;

View File

@@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { createMasterOptionMutation } from '../api/mutations';
import { masterOptionsQueryOptions } from '../api/queries';
import type { MasterOptionMutationPayload } from '../api/types';
import { MasterOptionsFormDialog } from './master-options-form-dialog';
export function MasterOptionsCreateAction() {
const [open, setOpen] = useState(false);
const { data } = useQuery(masterOptionsQueryOptions({ page: 1, limit: 200 }));
const createMutation = useMutation({
...createMasterOptionMutation,
onSuccess: () => {
toast.success('Master option created.');
setOpen(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Create failed');
}
});
async function handleSubmit(values: MasterOptionMutationPayload) {
await createMutation.mutateAsync(values);
}
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Add Option
</Button>
<MasterOptionsFormDialog
open={open}
onOpenChange={setOpen}
onSubmit={handleSubmit}
pending={createMutation.isPending}
availableParents={data?.items ?? []}
/>
</>
);
}

View File

@@ -138,18 +138,7 @@ export function MasterOptionsTable() {
return (
<DataTable table={table}>
<div className='flex flex-wrap items-center justify-between gap-3'>
<DataTableToolbar table={table} />
<Button
onClick={() => {
setEditingOption(undefined);
setIsFormOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Option
</Button>
</div>
<MasterOptionsFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}