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

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 {
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
};
return rows.map((row) => {
const branchId = row.branchId || null;
const branchDisplay = branchId ? (branchDisplayMap.get(branchId) ?? null) : null;
return {
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,
prefix: row.prefix,
period: row.period,
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>
<DataTableToolbar table={table} />
<MasterOptionsFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}