Hotfix Rule: Force Fresh Data After CRUD Mutation

Task-h not implements
This commit is contained in:
phaichayon
2026-06-16 11:59:41 +07:00
parent 357414c247
commit 56683ee7b9
16 changed files with 763 additions and 186 deletions

View File

@@ -375,6 +375,31 @@ Preferred pattern for new pages:
3. hydrate with `HydrationBoundary`
4. read with `useSuspenseQuery()`
Required pattern for CRUD mutations:
1. every feature must define centralized query key factories in `queries.ts`
2. use grouped keys such as `all`, `lists()`, `list(filters)`, `details()`, `detail(id)`, plus child-resource keys like `contacts(id)` or `followups(id)` when needed
3. after create, explicitly invalidate the list-level key
4. after update, explicitly invalidate both list and detail keys
5. after delete, explicitly invalidate the list key and remove stale detail queries with `removeQueries`
6. invalidate related queries affected by the mutation, such as counts, child tabs, document previews, approval panels, and cross-feature related lists
7. do not rely on `staleTime` alone to refresh production CRUD data
8. do not rely on `router.refresh()` alone for CRUD freshness; React Query invalidation is required first, and `router.refresh()` is optional only as a supplement
Mutation callback safety rule:
- if a shared mutation object from `api/mutations.ts` is spread into `useMutation({ ...sharedMutation, ... })`, do not override `onSuccess` or `onSettled` in a way that drops cache invalidation
- prefer keeping cache invalidation inside the shared mutation definition
- if component-level success behavior is needed, either:
- put invalidation in shared `onSettled` and keep UI concerns like toast and closing dialogs in component `onSuccess`, or
- call the shared invalidate helper explicitly from the component before closing the sheet/dialog
UI completion rule after successful mutation:
- close sheet/dialog only after the mutation promise resolves successfully
- table/detail views must show fresh data immediately without a full browser refresh
- related tabs and counts must refresh in the same interaction when their backing data changed
### URL State
Use `nuqs` for table search params and list filters.
@@ -486,3 +511,8 @@ Ensure these are set:
12. Treat legacy mock-backed modules as migration seams.
13. Follow the migrated `products` and `users` feature patterns when building new CRUD modules.
14. Do not re-enable self-service sign-up unless the product requirement changes explicitly.
15. For every CRUD mutation, explicitly invalidate React Query caches for list/detail/related data; never rely on passive staleness recovery.
16. Centralize query keys in each feature's `api/queries.ts`; do not scatter manual string query keys in components.
17. When deleting entities, remove stale detail queries with `removeQueries` in addition to invalidating lists.
18. If a component overrides `useMutation` callbacks on top of a shared mutation config, preserve or explicitly call the shared invalidation behavior before closing UI.
19. After successful create/update/delete, sheets, dialogs, and destructive modals must close only after cache invalidation has been triggered and the UI can refresh from fresh data.

View File

@@ -190,3 +190,21 @@ Future:
- approved PDF generator
- storage abstraction
- immutable approved artifact URL on quotation
## After Task G
### Template Mapping Editor
Mapping CRUD UI ยังไม่มี
Future: ทำ mapping editor สำหรับ admin
### Canvas-Accurate PDF Preview
Preview ปัจจุบันเป็น native app preview ไม่ใช่ pdfme canvas rendering
Future: เพิ่ม pdfme renderer preview
### Product-Specific Template Rule
ตอนนี้ใช้ productType = default
Future: define rule สำหรับ crane/dockdoor/solarcell
### PDF Binary Generation
ยังไม่มี generate/download/store PDF
Future: Task H

248
plans/task-h.md Normal file
View File

@@ -0,0 +1,248 @@
# Task H: PDF Generation + Approved Document Persistence
## ALLA OS CRM vNext
คุณคือ Senior Full-stack Engineer
ให้ implement PDF generation สำหรับ Quotation โดยต่อยอดจาก Task G
## ต้องอ่านก่อน
```txt
docs/implementation/task-a-template-audit.md
docs/implementation/technical-debt.md
docs/adr/0008-attachment-storage-strategy.md
src/features/foundation/document-template/**
src/features/crm/quotations/document/**
src/features/crm/quotations/**
src/features/foundation/approval/**
src/db/schema.ts
```
## Goal
สร้าง production foundation สำหรับ:
```txt
Generate PDF from pdfme template
Preview/download PDF
Persist approved PDF snapshot
Attach generated PDF metadata to quotation
```
## Scope H1: PDF Generation Service
สร้าง service:
```txt
src/features/foundation/pdf-generator/**
```
Functions:
```ts
generatePdfFromTemplate(input)
generateQuotationPdf(quotationId)
generateQuotationPreviewPdf(quotationId)
generateApprovedQuotationPdf(quotationId)
```
Rules:
* ใช้ templateVersion + schemaJson จาก Task G
* ใช้ `buildQuotationDocumentData`
* ใช้ `mapDocumentDataToTemplateInput`
* ห้าม generate จาก client
* validate organizationId ทุกครั้ง
## Scope H2: PDF Download APIs
เพิ่ม routes:
```txt
src/app/api/crm/quotations/[id]/pdf-preview/route.ts
src/app/api/crm/quotations/[id]/pdf-download/route.ts
src/app/api/crm/quotations/[id]/approved-pdf/route.ts
```
Behavior:
```txt
pdf-preview = generate PDF ชั่วคราวจาก current data
pdf-download = download quotation PDF
approved-pdf = generate/store approved PDF
```
Rules:
* preview/download ต้อง check permission
* approved-pdf ต้องอนุญาตเฉพาะ approved quotation
* response ต้องเป็น application/pdf
* filename ใช้ quotation code
## Scope H3: Approved Snapshot Persistence
เมื่อ quotation approved แล้ว หรือเมื่อกด generate approved PDF:
ให้ persist:
```txt
approvedSnapshot
approvedPdfUrl
approvedAt
templateVersionId
```
ถ้า schema ยังไม่มี field บางตัว ให้เพิ่มอย่างระวัง
Snapshot ต้องเก็บ:
```txt
quotation data
items
customers
topics
approval timeline
templateVersionId
templateInput
generatedAt
generatedBy
```
## Scope H4: Attachment Metadata Integration
หลัง generate approved PDF ให้สร้าง attachment metadata:
```txt
fileName
originalFileName
filePath
fileSize
fileType = application/pdf
description = Approved quotation PDF
uploadedBy
uploadedAt
```
ถ้ายังไม่มี storage provider จริง:
* ใช้ local/generated path placeholder ได้
* ระบุ technical debt ชัดเจน
## Scope H5: Quotation UI Integration
ใน quotation detail:
เพิ่ม actions:
```txt
Preview PDF
Download PDF
Generate Approved PDF
View Approved PDF
```
Rules:
* Preview PDF ใช้ได้กับ draft/pending/approved
* Generate Approved PDF ใช้ได้เฉพาะ approved
* ถ้ามี approvedPdfUrl แล้ว แสดง View Approved PDF
* แสดง template version ที่ใช้
## Scope H6: Approval Integration
หลัง final approval สำเร็จ:
```txt
quotation.status = approved
```
ให้เตรียม hook หรือ optional call:
```ts
prepareApprovedQuotationSnapshot()
```
ถ้า generate PDF อัตโนมัติยังเสี่ยง ให้ทำ manual action ก่อน
Recommendation:
```txt
Task H: manual generate approved PDF
Future: auto-generate after final approval
```
## Permissions
เพิ่มถ้ายังไม่มี:
```txt
crm.quotation.pdf.preview
crm.quotation.pdf.download
crm.quotation.pdf.generate_approved
```
## Audit
Audit actions:
```txt
crm_quotation_pdf_preview optional
crm_quotation_pdf_download optional
crm_quotation_pdf_generate_approved required
```
Preview/download audit ไม่บังคับ
Generate approved PDF ต้อง audit
## ห้ามทำ
```txt
Template drag/drop designer
Notification
Dashboard KPI
Report
External object storage production
Email sending
```
## Output
สรุป:
1. Files Added
2. Files Modified
3. PDF Generator Added
4. API Routes Added
5. UI Actions Added
6. Approved Snapshot Persistence
7. Attachment Metadata Integration
8. Permission Added
9. Audit Integration
10. Remaining Risks
11. Task I Readiness
## Definition of Done
Task H ผ่านเมื่อ:
✔ generate PDF จาก pdfme template ได้
✔ preview/download PDF ได้
✔ approved quotation generate approved PDF ได้
✔ approved snapshot ถูก persist
✔ attachment metadata ถูกสร้าง
✔ UI มี PDF actions
✔ permission guard ครบ
✔ audit generate approved PDF
✔ ยังไม่ทำ template designer
✔ ยังไม่ทำ notification/report

View File

@@ -31,6 +31,11 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmContactCreate)));
const canContactRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmContactRead)));
const canContactUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
@@ -45,6 +50,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
if (canRead) {
void queryClient.prefetchQuery(customerByIdOptions(id));
}
if (canContactRead) {
void queryClient.prefetchQuery(customerContactsOptions(id));
}
@@ -78,6 +86,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
customerId={id}
referenceData={referenceData}
canUpdate={canUpdate}
canReadContacts={canContactRead}
canManageContacts={{
create: canContactCreate,
update: canContactUpdate,

View File

@@ -11,26 +11,57 @@ import {
import { customerKeys } from './queries';
import type { CustomerContactMutationPayload, CustomerMutationPayload } from './types';
async function invalidateCustomerLists() {
await getQueryClient().invalidateQueries({ queryKey: customerKeys.lists() });
}
async function invalidateCustomerDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(id) });
}
async function invalidateCustomerContacts(id: string) {
await getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(id) });
}
export async function invalidateCustomerMutationQueries(id: string) {
await Promise.all([invalidateCustomerLists(), invalidateCustomerDetail(id)]);
}
export async function invalidateCustomerContactMutationQueries(customerId: string) {
await Promise.all([
invalidateCustomerLists(),
invalidateCustomerDetail(customerId),
invalidateCustomerContacts(customerId)
]);
}
export const createCustomerMutation = mutationOptions({
mutationFn: (data: CustomerMutationPayload) => createCustomer(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
onSettled: async (_data, error) => {
if (!error) {
await invalidateCustomerLists();
}
}
});
export const updateCustomerMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: CustomerMutationPayload }) =>
updateCustomer(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.id) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateCustomerMutationQueries(variables.id);
}
}
});
export const deleteCustomerMutation = mutationOptions({
mutationFn: (id: string) => deleteCustomer(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: customerKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: customerKeys.contacts(id) });
await invalidateCustomerLists();
}
}
});
@@ -42,9 +73,10 @@ export const createCustomerContactMutation = mutationOptions({
customerId: string;
values: CustomerContactMutationPayload;
}) => createCustomerContact(customerId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateCustomerContactMutationQueries(variables.customerId);
}
}
});
@@ -58,17 +90,19 @@ export const updateCustomerContactMutation = mutationOptions({
contactId: string;
values: CustomerContactMutationPayload;
}) => updateCustomerContact(customerId, contactId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateCustomerContactMutationQueries(variables.customerId);
}
}
});
export const deleteCustomerContactMutation = mutationOptions({
mutationFn: ({ customerId, contactId }: { customerId: string; contactId: string }) =>
deleteCustomerContact(customerId, contactId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateCustomerContactMutationQueries(variables.customerId);
}
}
});

View File

@@ -4,9 +4,12 @@ import type { CustomerFilters } from './types';
export const customerKeys = {
all: ['crm-customers'] as const,
list: (filters: CustomerFilters) => [...customerKeys.all, 'list', filters] as const,
detail: (id: string) => [...customerKeys.all, 'detail', id] as const,
contacts: (id: string) => [...customerKeys.all, 'contacts', id] as const
lists: () => [...customerKeys.all, 'list'] as const,
list: (filters: CustomerFilters) => [...customerKeys.lists(), filters] as const,
details: () => [...customerKeys.all, 'detail'] as const,
detail: (id: string) => [...customerKeys.details(), id] as const,
contactsRoot: () => [...customerKeys.all, 'contacts'] as const,
contacts: (id: string) => [...customerKeys.contactsRoot(), id] as const
};
export const customersQueryOptions = (filters: CustomerFilters) =>

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { formatDateTime } from '@/lib/format';
import { customerByIdOptions } from '../api/queries';
import type { CustomerReferenceData } from '../api/types';
import { CustomerFormSheet } from './customer-form-sheet';
@@ -30,6 +31,7 @@ export function CustomerDetail({
customerId,
referenceData,
canUpdate,
canReadContacts,
canManageContacts,
relatedEnquiries,
relatedQuotations
@@ -37,6 +39,7 @@ export function CustomerDetail({
customerId: string;
referenceData: CustomerReferenceData;
canUpdate: boolean;
canReadContacts: boolean;
canManageContacts: {
create: boolean;
update: boolean;
@@ -113,7 +116,7 @@ export function CustomerDetail({
<Tabs defaultValue='overview' className='gap-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
{canReadContacts ? <TabsTrigger value='contacts'>Contacts</TabsTrigger> : null}
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='related'>Related Documents</TabsTrigger>
</TabsList>
@@ -166,14 +169,16 @@ export function CustomerDetail({
</CardContent>
</Card>
</TabsContent>
<TabsContent value='contacts'>
<CustomerContactsTab
customerId={customerId}
canCreate={canManageContacts.create}
canUpdate={canManageContacts.update}
canDelete={canManageContacts.delete}
/>
</TabsContent>
{canReadContacts ? (
<TabsContent value='contacts'>
<CustomerContactsTab
customerId={customerId}
canCreate={canManageContacts.create}
canUpdate={canManageContacts.update}
canDelete={canManageContacts.delete}
/>
</TabsContent>
) : null}
<TabsContent value='activity'>
<Card>
<CardHeader>
@@ -202,7 +207,7 @@ export function CustomerDetail({
</span>
</div>
<div className='text-muted-foreground text-xs'>
{new Date(item.createdAt).toLocaleString()}
{formatDateTime(item.createdAt)}
</div>
</div>
</div>
@@ -281,8 +286,8 @@ export function CustomerDetail({
<CardContent className='space-y-4'>
<FieldItem label='Created By' value={customer.createdBy} />
<FieldItem label='Updated By' value={customer.updatedBy} />
<FieldItem label='Created At' value={new Date(customer.createdAt).toLocaleString()} />
<FieldItem label='Updated At' value={new Date(customer.updatedAt).toLocaleString()} />
<FieldItem label='Created At' value={formatDateTime(customer.createdAt)} />
<FieldItem label='Updated At' value={formatDateTime(customer.updatedAt)} />
</CardContent>
</Card>
</div>

View File

@@ -11,26 +11,57 @@ import {
import { enquiryKeys } from './queries';
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
async function invalidateEnquiryLists() {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
}
async function invalidateEnquiryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
}
async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([invalidateEnquiryLists(), invalidateEnquiryDetail(id)]);
}
export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) {
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(enquiryId),
invalidateEnquiryFollowups(enquiryId)
]);
}
export const createEnquiryMutation = mutationOptions({
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
onSettled: async (_data, error) => {
if (!error) {
await invalidateEnquiryLists();
}
}
});
export const updateEnquiryMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) =>
updateEnquiry(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.id) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const deleteEnquiryMutation = mutationOptions({
mutationFn: (id: string) => deleteEnquiry(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
await invalidateEnquiryLists();
}
}
});
@@ -42,9 +73,10 @@ export const createEnquiryFollowupMutation = mutationOptions({
enquiryId: string;
values: EnquiryFollowupMutationPayload;
}) => createEnquiryFollowup(enquiryId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});
@@ -58,17 +90,19 @@ export const updateEnquiryFollowupMutation = mutationOptions({
followupId: string;
values: EnquiryFollowupMutationPayload;
}) => updateEnquiryFollowup(enquiryId, followupId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});
export const deleteEnquiryFollowupMutation = mutationOptions({
mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) =>
deleteEnquiryFollowup(enquiryId, followupId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});

View File

@@ -4,9 +4,12 @@ import type { EnquiryFilters } from './types';
export const enquiryKeys = {
all: ['crm-enquiries'] as const,
list: (filters: EnquiryFilters) => [...enquiryKeys.all, 'list', filters] as const,
detail: (id: string) => [...enquiryKeys.all, 'detail', id] as const,
followups: (id: string) => [...enquiryKeys.all, 'followups', id] as const
lists: () => [...enquiryKeys.all, 'list'] as const,
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
details: () => [...enquiryKeys.all, 'detail'] as const,
detail: (id: string) => [...enquiryKeys.details(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
};
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>

View File

@@ -32,43 +32,104 @@ import type {
QuotationTopicMutationPayload
} from './types';
function invalidateQuotationDocumentQueries(quotationId: string) {
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
async function invalidateQuotationLists() {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.lists() });
}
async function invalidateQuotationDetail(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
}
async function invalidateQuotationDocumentQueries(quotationId: string) {
await Promise.all([
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) }),
getQueryClient().invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) })
]);
}
async function invalidateQuotationHeaderQueries(quotationId: string) {
await Promise.all([
invalidateQuotationLists(),
invalidateQuotationDetail(quotationId),
invalidateQuotationDocumentQueries(quotationId)
]);
}
async function invalidateQuotationItems(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(quotationId) });
}
async function invalidateQuotationCustomers(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.customers(quotationId) });
}
async function invalidateQuotationTopics(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(quotationId) });
}
async function invalidateQuotationFollowups(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.followups(quotationId) });
}
async function invalidateQuotationAttachments(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.attachments(quotationId) });
}
async function invalidateQuotationRevisions(quotationId: string) {
await getQueryClient().invalidateQueries({ queryKey: quotationKeys.revisions(quotationId) });
}
async function removeQuotationDetailQueries(quotationId: string) {
getQueryClient().removeQueries({ queryKey: quotationKeys.detail(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.items(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.customers(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.topics(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.followups(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.attachments(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationKeys.revisions(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
getQueryClient().removeQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
}
export const createQuotationMutation = mutationOptions({
mutationFn: (data: QuotationMutationPayload) => createQuotation(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
onSettled: async (_data, error) => {
if (!error) {
await invalidateQuotationLists();
}
}
});
export const updateQuotationMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: QuotationMutationPayload }) =>
updateQuotation(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) });
invalidateQuotationDocumentQueries(variables.id);
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateQuotationHeaderQueries(variables.id);
}
}
});
export const deleteQuotationMutation = mutationOptions({
mutationFn: (id: string) => deleteQuotation(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
onSettled: async (_data, error, id) => {
if (!error) {
await removeQuotationDetailQueries(id);
await invalidateQuotationLists();
}
}
});
export const createQuotationItemMutation = mutationOptions({
mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationItemMutationPayload }) =>
createQuotationItem(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationItems(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
@@ -82,22 +143,26 @@ export const updateQuotationItemMutation = mutationOptions({
itemId: string;
values: QuotationItemMutationPayload;
}) => updateQuotationItem(quotationId, itemId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationItems(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
export const deleteQuotationItemMutation = mutationOptions({
mutationFn: ({ quotationId, itemId }: { quotationId: string; itemId: string }) =>
deleteQuotationItem(quotationId, itemId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationItems(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
@@ -109,12 +174,13 @@ export const createQuotationCustomerMutation = mutationOptions({
quotationId: string;
values: QuotationCustomerMutationPayload;
}) => createQuotationCustomer(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.customers(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationCustomers(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
@@ -128,51 +194,68 @@ export const updateQuotationCustomerMutation = mutationOptions({
relationId: string;
values: QuotationCustomerMutationPayload;
}) => updateQuotationCustomer(quotationId, relationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.customers(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationCustomers(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
export const deleteQuotationCustomerMutation = mutationOptions({
mutationFn: ({ quotationId, relationId }: { quotationId: string; relationId: string }) =>
deleteQuotationCustomer(quotationId, relationId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.customers(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationCustomers(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});
export const createQuotationTopicMutation = mutationOptions({
mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationTopicMutationPayload }) =>
createQuotationTopic(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationTopics(variables.quotationId),
invalidateQuotationDetail(variables.quotationId),
invalidateQuotationDocumentQueries(variables.quotationId)
]);
}
}
});
export const updateQuotationTopicMutation = mutationOptions({
mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationTopicMutationPayload }) =>
updateQuotationTopic(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationTopics(variables.quotationId),
invalidateQuotationDetail(variables.quotationId),
invalidateQuotationDocumentQueries(variables.quotationId)
]);
}
}
});
export const deleteQuotationTopicMutation = mutationOptions({
mutationFn: ({ quotationId, topicId }: { quotationId: string; topicId: string }) =>
deleteQuotationTopic(quotationId, topicId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
invalidateQuotationDocumentQueries(variables.quotationId);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationTopics(variables.quotationId),
invalidateQuotationDetail(variables.quotationId),
invalidateQuotationDocumentQueries(variables.quotationId)
]);
}
}
});
@@ -184,11 +267,13 @@ export const createQuotationFollowupMutation = mutationOptions({
quotationId: string;
values: QuotationFollowupMutationPayload;
}) => createQuotationFollowup(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.followups(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationFollowups(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
@@ -200,22 +285,26 @@ export const updateQuotationFollowupMutation = mutationOptions({
quotationId: string;
values: QuotationFollowupMutationPayload;
}) => updateQuotationFollowup(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.followups(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationFollowups(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
export const deleteQuotationFollowupMutation = mutationOptions({
mutationFn: ({ quotationId, followupId }: { quotationId: string; followupId: string }) =>
deleteQuotationFollowup(quotationId, followupId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.followups(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationFollowups(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
@@ -227,10 +316,13 @@ export const createQuotationAttachmentMutation = mutationOptions({
quotationId: string;
values: QuotationAttachmentMutationPayload;
}) => createQuotationAttachment(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.attachments(variables.quotationId)
});
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationAttachments(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
@@ -242,20 +334,26 @@ export const updateQuotationAttachmentMutation = mutationOptions({
quotationId: string;
values: QuotationAttachmentMutationPayload;
}) => updateQuotationAttachment(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.attachments(variables.quotationId)
});
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationAttachments(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
export const deleteQuotationAttachmentMutation = mutationOptions({
mutationFn: ({ quotationId, attachmentId }: { quotationId: string; attachmentId: string }) =>
deleteQuotationAttachment(quotationId, attachmentId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.attachments(variables.quotationId)
});
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationAttachments(variables.quotationId),
invalidateQuotationDetail(variables.quotationId)
]);
}
}
});
@@ -267,8 +365,12 @@ export const createQuotationRevisionMutation = mutationOptions({
quotationId: string;
revisionRemark?: string;
}) => createQuotationRevision(quotationId, revisionRemark),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.revisions(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateQuotationRevisions(variables.quotationId),
invalidateQuotationHeaderQueries(variables.quotationId)
]);
}
}
});

View File

@@ -13,14 +13,22 @@ import type { QuotationFilters } from './types';
export const quotationKeys = {
all: ['crm-quotations'] as const,
list: (filters: QuotationFilters) => [...quotationKeys.all, 'list', filters] as const,
detail: (id: string) => [...quotationKeys.all, 'detail', id] as const,
items: (id: string) => [...quotationKeys.all, 'items', id] as const,
customers: (id: string) => [...quotationKeys.all, 'customers', id] as const,
topics: (id: string) => [...quotationKeys.all, 'topics', id] as const,
followups: (id: string) => [...quotationKeys.all, 'followups', id] as const,
attachments: (id: string) => [...quotationKeys.all, 'attachments', id] as const,
revisions: (id: string) => [...quotationKeys.all, 'revisions', id] as const
lists: () => [...quotationKeys.all, 'list'] as const,
list: (filters: QuotationFilters) => [...quotationKeys.lists(), filters] as const,
details: () => [...quotationKeys.all, 'detail'] as const,
detail: (id: string) => [...quotationKeys.details(), id] as const,
itemsRoot: () => [...quotationKeys.all, 'items'] as const,
items: (id: string) => [...quotationKeys.itemsRoot(), id] as const,
customersRoot: () => [...quotationKeys.all, 'customers'] as const,
customers: (id: string) => [...quotationKeys.customersRoot(), id] as const,
topicsRoot: () => [...quotationKeys.all, 'topics'] as const,
topics: (id: string) => [...quotationKeys.topicsRoot(), id] as const,
followupsRoot: () => [...quotationKeys.all, 'followups'] as const,
followups: (id: string) => [...quotationKeys.followupsRoot(), id] as const,
attachmentsRoot: () => [...quotationKeys.all, 'attachments'] as const,
attachments: (id: string) => [...quotationKeys.attachmentsRoot(), id] as const,
revisionsRoot: () => [...quotationKeys.all, 'revisions'] as const,
revisions: (id: string) => [...quotationKeys.revisionsRoot(), id] as const
};
export const quotationsQueryOptions = (filters: QuotationFilters) =>

View File

@@ -13,75 +13,104 @@ import { approvalKeys } from './queries';
import { quotationKeys } from '@/features/crm/quotations/api/queries';
import type { ApprovalActionPayload, SubmitApprovalPayload } from './types';
function invalidateQuotationQueries(quotationId: string) {
const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: quotationKeys.all });
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) });
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) });
async function invalidateApprovalLists() {
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.lists() });
}
function invalidateRelatedEntityQueries(approvalId: string) {
async function invalidateApprovalDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
}
async function invalidateQuotationQueries(quotationId: string) {
const queryClient = getQueryClient();
await Promise.all([
queryClient.invalidateQueries({ queryKey: quotationKeys.lists() }),
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }),
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) }),
queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) })
]);
}
async function invalidateRelatedEntityQueries(approvalId: string) {
const queryClient = getQueryClient();
const cachedDetail = queryClient.getQueryData<{ approval: { request: { entityType: string; entityId: string } } }>(
approvalKeys.detail(approvalId)
);
if (cachedDetail?.approval.request.entityType === 'quotation') {
invalidateQuotationQueries(cachedDetail.approval.request.entityId);
await invalidateQuotationQueries(cachedDetail.approval.request.entityId);
}
}
export const submitApprovalMutation = mutationOptions({
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
onSettled: async (_data, error) => {
if (!error) {
await invalidateApprovalLists();
}
}
});
export const submitQuotationApprovalMutation = mutationOptions({
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
invalidateQuotationQueries(variables.id);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateApprovalLists(), invalidateQuotationQueries(variables.id)]);
}
}
});
export const cancelApprovalMutation = mutationOptions({
mutationFn: (id: string) => cancelApproval(id),
onSuccess: (_data, id) => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
invalidateRelatedEntityQueries(id);
onSettled: async (_data, error, id) => {
if (!error) {
await Promise.all([
invalidateApprovalLists(),
invalidateApprovalDetail(id),
invalidateRelatedEntityQueries(id)
]);
}
}
});
export const approveApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
approveApproval(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
invalidateRelatedEntityQueries(variables.id);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalLists(),
invalidateApprovalDetail(variables.id),
invalidateRelatedEntityQueries(variables.id)
]);
}
}
});
export const rejectApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
rejectApproval(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
invalidateRelatedEntityQueries(variables.id);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalLists(),
invalidateApprovalDetail(variables.id),
invalidateRelatedEntityQueries(variables.id)
]);
}
}
});
export const returnApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
returnApproval(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
invalidateRelatedEntityQueries(variables.id);
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalLists(),
invalidateApprovalDetail(variables.id),
invalidateRelatedEntityQueries(variables.id)
]);
}
}
});

View File

@@ -4,8 +4,10 @@ import type { ApprovalFilters } from './types';
export const approvalKeys = {
all: ['crm-approvals'] as const,
list: (filters: ApprovalFilters) => [...approvalKeys.all, 'list', filters] as const,
detail: (id: string) => [...approvalKeys.all, 'detail', id] as const
lists: () => [...approvalKeys.all, 'list'] as const,
list: (filters: ApprovalFilters) => [...approvalKeys.lists(), filters] as const,
details: () => [...approvalKeys.all, 'detail'] as const,
detail: (id: string) => [...approvalKeys.details(), id] as const
};
export const approvalsQueryOptions = (filters: ApprovalFilters) =>

View File

@@ -9,35 +9,61 @@ import {
import { documentTemplateKeys } from './queries';
import type { DocumentTemplateMutationPayload, DocumentTemplateVersionMutationPayload } from './types';
async function invalidateDocumentTemplateLists() {
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.lists() });
}
async function invalidateDocumentTemplateDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) });
}
async function invalidateDocumentTemplateVersions(id: string) {
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) });
}
export const createDocumentTemplateMutation = mutationOptions({
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
onSettled: async (_data, error) => {
if (!error) {
await invalidateDocumentTemplateLists();
}
}
});
export const updateDocumentTemplateMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: Partial<DocumentTemplateMutationPayload> }) =>
updateDocumentTemplate(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateDocumentTemplateLists(),
invalidateDocumentTemplateDetail(variables.id)
]);
}
}
});
export const deleteDocumentTemplateMutation = mutationOptions({
mutationFn: (id: string) => deleteDocumentTemplate(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.versions(id) });
await invalidateDocumentTemplateLists();
}
}
});
export const createDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: DocumentTemplateVersionMutationPayload }) =>
createDocumentTemplateVersion(id, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.all });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(variables.id) });
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(variables.id) });
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateDocumentTemplateLists(),
invalidateDocumentTemplateDetail(variables.id),
invalidateDocumentTemplateVersions(variables.id)
]);
}
}
});

View File

@@ -4,10 +4,13 @@ import type { DocumentTemplateFilters } from './types';
export const documentTemplateKeys = {
all: ['crm-document-templates'] as const,
lists: () => [...documentTemplateKeys.all, 'list'] as const,
list: (filters: DocumentTemplateFilters) =>
[...documentTemplateKeys.all, 'list', filters] as const,
detail: (id: string) => [...documentTemplateKeys.all, 'detail', id] as const,
versions: (id: string) => [...documentTemplateKeys.all, 'versions', id] as const
[...documentTemplateKeys.lists(), filters] as const,
details: () => [...documentTemplateKeys.all, 'detail'] as const,
detail: (id: string) => [...documentTemplateKeys.details(), id] as const,
versionsRoot: () => [...documentTemplateKeys.all, 'versions'] as const,
versions: (id: string) => [...documentTemplateKeys.versionsRoot(), id] as const
};
export const documentTemplatesQueryOptions = (filters: DocumentTemplateFilters = {}) =>

View File

@@ -16,6 +16,29 @@ export function formatDate(
}
}
export function formatDateTime(
date: Date | string | number | undefined,
opts: Intl.DateTimeFormatOptions = {}
) {
if (!date) return '';
try {
return new Intl.DateTimeFormat('th-TH-u-ca-gregory-nu-latn', {
day: opts.day ?? '2-digit',
month: opts.month ?? '2-digit',
year: opts.year ?? 'numeric',
hour: opts.hour ?? '2-digit',
minute: opts.minute ?? '2-digit',
second: opts.second ?? '2-digit',
hour12: opts.hour12 ?? false,
timeZone: opts.timeZone ?? 'Asia/Bangkok',
...opts
}).format(new Date(date));
} catch {
return '';
}
}
/**
* Formats a date to YYYY-MM-DD format (ISO date).
* This is used to prevent hydration mismatches between server and client.