This commit is contained in:
phaichayon
2026-06-15 16:34:41 +07:00
parent 1d2406483e
commit eae0dee1f2
40 changed files with 9982 additions and 132 deletions

View File

@@ -15,6 +15,7 @@ import { CustomerFormSheet } from './customer-form-sheet';
import { CustomerContactsTab } from './customer-contacts-tab';
import { CustomerStatusBadge } from './customer-status-badge';
import type { CustomerRelatedEnquiryItem } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
@@ -30,7 +31,8 @@ export function CustomerDetail({
referenceData,
canUpdate,
canManageContacts,
relatedEnquiries
relatedEnquiries,
relatedQuotations
}: {
customerId: string;
referenceData: CustomerReferenceData;
@@ -41,6 +43,7 @@ export function CustomerDetail({
delete: boolean;
};
relatedEnquiries: CustomerRelatedEnquiryItem[];
relatedQuotations: QuotationRelationItem[];
}) {
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
const [editOpen, setEditOpen] = useState(false);
@@ -215,14 +218,13 @@ export function CustomerDetail({
<CardHeader>
<CardTitle>Related Documents</CardTitle>
<CardDescription>
Production enquiry links are now visible here, while quotation and approval
modules stay deferred.
Production enquiry and quotation links for this customer.
</CardDescription>
</CardHeader>
<CardContent>
{!relatedEnquiries.length ? (
{!relatedEnquiries.length && !relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No enquiries have been linked to this customer yet.
No enquiries or quotations have been linked to this customer yet.
</div>
) : (
<div className='space-y-3'>
@@ -243,6 +245,23 @@ export function CustomerDetail({
</div>
</Link>
))}
{relatedQuotations.map((quotation) => (
<Link
key={quotation.id}
href={`/dashboard/crm/quotations/${quotation.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='font-medium'>{quotation.code}</div>
<div className='text-muted-foreground text-xs'>
{quotation.totalAmount.toLocaleString()}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
</div>
</Link>
))}
</div>
)}
</CardContent>

View File

@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { enquiryByIdOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryStatusBadge } from './enquiry-status-badge';
@@ -27,11 +28,13 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
export function EnquiryDetail({
enquiryId,
referenceData,
relatedQuotations,
canUpdate,
canManageFollowups
}: {
enquiryId: string;
referenceData: EnquiryReferenceData;
relatedQuotations: QuotationRelationItem[];
canUpdate: boolean;
canManageFollowups: {
create: boolean;
@@ -245,14 +248,35 @@ export function EnquiryDetail({
<CardHeader>
<CardTitle>Related Quotations</CardTitle>
<CardDescription>
Task D ends at enquiry readiness, so quotation production stays
intentionally deferred.
Production quotations already linked to this enquiry.
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
Quotation linkage will land here in Task E.
</div>
{!relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No quotations have been linked to this enquiry yet.
</div>
) : (
<div className='space-y-3'>
{relatedQuotations.map((quotation) => (
<Link
key={quotation.id}
href={`/dashboard/crm/quotations/${quotation.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='font-medium'>{quotation.code}</div>
<div className='text-muted-foreground text-sm'>
{quotation.totalAmount.toLocaleString()}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>

View File

@@ -178,13 +178,17 @@ async function assertMasterOptionValue(
}
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
const customer = await db.query.crmCustomers.findFirst({
where: and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
const [customer] = await db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
)
)
});
.limit(1);
if (!customer) {
throw new AuthError('Customer not found', 404);
@@ -198,14 +202,18 @@ export async function assertContactBelongsToOrganization(
organizationId: string,
customerId?: string | null
) {
const contact = await db.query.crmCustomerContacts.findFirst({
where: and(
eq(crmCustomerContacts.id, contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt),
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
const [contact] = await db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.id, contactId),
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt),
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
)
)
});
.limit(1);
if (!contact) {
throw new AuthError('Contact not found', 404);
@@ -215,13 +223,17 @@ export async function assertContactBelongsToOrganization(
}
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
const enquiry = await db.query.crmEnquiries.findFirst({
where: and(
eq(crmEnquiries.id, id),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
const [enquiry] = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.id, id),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
)
});
.limit(1);
if (!enquiry) {
throw new AuthError('Enquiry not found', 404);
@@ -235,14 +247,18 @@ async function assertFollowupBelongsToEnquiry(
enquiryId: string,
organizationId: string
) {
const followup = await db.query.crmEnquiryFollowups.findFirst({
where: and(
eq(crmEnquiryFollowups.id, followupId),
eq(crmEnquiryFollowups.enquiryId, enquiryId),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
const [followup] = await db
.select()
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.id, followupId),
eq(crmEnquiryFollowups.enquiryId, enquiryId),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
});
.limit(1);
if (!followup) {
throw new AuthError('Follow-up not found', 404);
@@ -345,17 +361,21 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
db.query.crmCustomers.findMany({
where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)),
orderBy: [asc(crmCustomers.name)]
}),
db.query.crmCustomerContacts.findMany({
where: and(
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
),
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
})
db
.select()
.from(crmCustomers)
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
.orderBy(asc(crmCustomers.name)),
db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.organizationId, organizationId),
isNull(crmCustomerContacts.deletedAt)
)
)
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
]);
return {
@@ -407,20 +427,26 @@ export async function listEnquiries(
const [customers, contacts, followupCounts] = await Promise.all([
customerIds.length
? db.query.crmCustomers.findMany({
where: and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, customerIds)
? db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, customerIds)
)
)
})
: [],
contactIds.length
? db.query.crmCustomerContacts.findMany({
where: and(
eq(crmCustomerContacts.organizationId, organizationId),
inArray(crmCustomerContacts.id, contactIds)
? db
.select()
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.organizationId, organizationId),
inArray(crmCustomerContacts.id, contactIds)
)
)
})
: [],
enquiryIds.length
? db
@@ -618,14 +644,17 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
export async function listEnquiryFollowups(id: string, organizationId: string) {
await assertEnquiryBelongsToOrganization(id, organizationId);
const rows = await db.query.crmEnquiryFollowups.findMany({
where: and(
eq(crmEnquiryFollowups.enquiryId, id),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
),
orderBy: [desc(crmEnquiryFollowups.followupDate)]
});
const rows = await db
.select()
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.enquiryId, id),
eq(crmEnquiryFollowups.organizationId, organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(desc(crmEnquiryFollowups.followupDate));
return rows.map(mapFollowupRecord);
}
@@ -713,15 +742,18 @@ export async function listCustomerEnquiryRelations(
customerId: string,
organizationId: string
): Promise<EnquiryCustomerRelationItem[]> {
const rows = await db.query.crmEnquiries.findMany({
where: and(
eq(crmEnquiries.customerId, customerId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
),
orderBy: [desc(crmEnquiries.updatedAt)],
limit: 10
});
const rows = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.customerId, customerId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
)
.orderBy(desc(crmEnquiries.updatedAt))
.limit(10);
return rows.map((row) => ({
id: row.id,

View File

@@ -0,0 +1,258 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createQuotation,
createQuotationAttachment,
createQuotationCustomer,
createQuotationFollowup,
createQuotationItem,
createQuotationRevision,
createQuotationTopic,
deleteQuotation,
deleteQuotationAttachment,
deleteQuotationCustomer,
deleteQuotationFollowup,
deleteQuotationItem,
deleteQuotationTopic,
updateQuotation,
updateQuotationAttachment,
updateQuotationCustomer,
updateQuotationFollowup,
updateQuotationItem,
updateQuotationTopic
} from './service';
import { quotationKeys } from './queries';
import type {
QuotationAttachmentMutationPayload,
QuotationCustomerMutationPayload,
QuotationFollowupMutationPayload,
QuotationItemMutationPayload,
QuotationMutationPayload,
QuotationTopicMutationPayload
} from './types';
export const createQuotationMutation = mutationOptions({
mutationFn: (data: QuotationMutationPayload) => createQuotation(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
}
});
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) });
}
});
export const deleteQuotationMutation = mutationOptions({
mutationFn: (id: string) => deleteQuotation(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
}
});
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 });
}
});
export const updateQuotationItemMutation = mutationOptions({
mutationFn: ({
quotationId,
itemId,
values
}: {
quotationId: string;
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 });
}
});
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 });
}
});
export const createQuotationCustomerMutation = mutationOptions({
mutationFn: ({
quotationId,
values
}: {
quotationId: string;
values: QuotationCustomerMutationPayload;
}) => createQuotationCustomer(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.customers(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
}
});
export const updateQuotationCustomerMutation = mutationOptions({
mutationFn: ({
quotationId,
relationId,
values
}: {
quotationId: string;
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) });
}
});
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) });
}
});
export const createQuotationTopicMutation = mutationOptions({
mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationTopicMutationPayload }) =>
createQuotationTopic(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(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) });
}
});
export const deleteQuotationTopicMutation = mutationOptions({
mutationFn: ({ quotationId, topicId }: { quotationId: string; topicId: string }) =>
deleteQuotationTopic(quotationId, topicId),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) });
}
});
export const createQuotationFollowupMutation = mutationOptions({
mutationFn: ({
quotationId,
values
}: {
quotationId: string;
values: QuotationFollowupMutationPayload;
}) => createQuotationFollowup(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.followups(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) });
}
});
export const updateQuotationFollowupMutation = mutationOptions({
mutationFn: ({
quotationId,
values
}: {
quotationId: string;
values: QuotationFollowupMutationPayload;
}) => updateQuotationFollowup(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.followups(variables.quotationId)
});
getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(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) });
}
});
export const createQuotationAttachmentMutation = mutationOptions({
mutationFn: ({
quotationId,
values
}: {
quotationId: string;
values: QuotationAttachmentMutationPayload;
}) => createQuotationAttachment(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.attachments(variables.quotationId)
});
}
});
export const updateQuotationAttachmentMutation = mutationOptions({
mutationFn: ({
quotationId,
values
}: {
quotationId: string;
values: QuotationAttachmentMutationPayload;
}) => updateQuotationAttachment(quotationId, values),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({
queryKey: quotationKeys.attachments(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)
});
}
});
export const createQuotationRevisionMutation = mutationOptions({
mutationFn: ({
quotationId,
revisionRemark
}: {
quotationId: string;
revisionRemark?: string;
}) => createQuotationRevision(quotationId, revisionRemark),
onSuccess: (_data, variables) => {
getQueryClient().invalidateQueries({ queryKey: quotationKeys.revisions(variables.quotationId) });
getQueryClient().invalidateQueries({ queryKey: quotationKeys.all });
}
});

View File

@@ -0,0 +1,72 @@
import { queryOptions } from '@tanstack/react-query';
import {
getQuotationAttachments,
getQuotationById,
getQuotationCustomers,
getQuotationFollowups,
getQuotationItems,
getQuotationRevisions,
getQuotations,
getQuotationTopics
} from './service';
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
};
export const quotationsQueryOptions = (filters: QuotationFilters) =>
queryOptions({
queryKey: quotationKeys.list(filters),
queryFn: () => getQuotations(filters)
});
export const quotationByIdOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.detail(id),
queryFn: () => getQuotationById(id)
});
export const quotationItemsOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.items(id),
queryFn: () => getQuotationItems(id)
});
export const quotationCustomersOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.customers(id),
queryFn: () => getQuotationCustomers(id)
});
export const quotationTopicsOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.topics(id),
queryFn: () => getQuotationTopics(id)
});
export const quotationFollowupsOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.followups(id),
queryFn: () => getQuotationFollowups(id)
});
export const quotationAttachmentsOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.attachments(id),
queryFn: () => getQuotationAttachments(id)
});
export const quotationRevisionsOptions = (id: string) =>
queryOptions({
queryKey: quotationKeys.revisions(id),
queryFn: () => getQuotationRevisions(id)
});

View File

@@ -0,0 +1,214 @@
import { apiClient } from '@/lib/api-client';
import type {
MutationSuccessResponse,
QuotationAttachmentMutationPayload,
QuotationAttachmentsResponse,
QuotationCustomerMutationPayload,
QuotationCustomersResponse,
QuotationDetailResponse,
QuotationFilters,
QuotationFollowupMutationPayload,
QuotationFollowupsResponse,
QuotationItemMutationPayload,
QuotationItemsResponse,
QuotationListResponse,
QuotationMutationPayload,
QuotationRevisionsResponse,
QuotationTopicMutationPayload,
QuotationTopicsResponse
} from './types';
export async function getQuotations(filters: QuotationFilters): Promise<QuotationListResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.quotationType) searchParams.set('quotationType', filters.quotationType);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.enquiry) searchParams.set('enquiry', filters.enquiry);
if (filters.hot) searchParams.set('hot', filters.hot);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<QuotationListResponse>(`/crm/quotations${query ? `?${query}` : ''}`);
}
export async function getQuotationById(id: string): Promise<QuotationDetailResponse> {
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
}
export async function createQuotation(data: QuotationMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/quotations', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotation(id: string, data: QuotationMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteQuotation(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}`, {
method: 'DELETE'
});
}
export async function getQuotationItems(id: string): Promise<QuotationItemsResponse> {
return apiClient<QuotationItemsResponse>(`/crm/quotations/${id}/items`);
}
export async function createQuotationItem(id: string, data: QuotationItemMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/items`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotationItem(
quotationId: string,
itemId: string,
data: QuotationItemMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteQuotationItem(quotationId: string, itemId: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/items/${itemId}`, {
method: 'DELETE'
});
}
export async function getQuotationCustomers(id: string): Promise<QuotationCustomersResponse> {
return apiClient<QuotationCustomersResponse>(`/crm/quotations/${id}/customers`);
}
export async function createQuotationCustomer(id: string, data: QuotationCustomerMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/customers`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotationCustomer(
quotationId: string,
relationId: string,
data: QuotationCustomerMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/customers`, {
method: 'PATCH',
body: JSON.stringify({ id: relationId, ...data })
});
}
export async function deleteQuotationCustomer(quotationId: string, relationId: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/customers`, {
method: 'DELETE',
body: JSON.stringify({ id: relationId })
});
}
export async function getQuotationTopics(id: string): Promise<QuotationTopicsResponse> {
return apiClient<QuotationTopicsResponse>(`/crm/quotations/${id}/topics`);
}
export async function createQuotationTopic(id: string, data: QuotationTopicMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/topics`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotationTopic(id: string, data: QuotationTopicMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/topics`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteQuotationTopic(quotationId: string, topicId: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/topics`, {
method: 'DELETE',
body: JSON.stringify({ id: topicId })
});
}
export async function getQuotationFollowups(id: string): Promise<QuotationFollowupsResponse> {
return apiClient<QuotationFollowupsResponse>(`/crm/quotations/${id}/followups`);
}
export async function createQuotationFollowup(id: string, data: QuotationFollowupMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/followups`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotationFollowup(
quotationId: string,
data: QuotationFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/followups`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteQuotationFollowup(quotationId: string, followupId: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/followups`, {
method: 'DELETE',
body: JSON.stringify({ id: followupId })
});
}
export async function getQuotationAttachments(id: string): Promise<QuotationAttachmentsResponse> {
return apiClient<QuotationAttachmentsResponse>(`/crm/quotations/${id}/attachments`);
}
export async function createQuotationAttachment(
id: string,
data: QuotationAttachmentMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/attachments`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateQuotationAttachment(
quotationId: string,
data: QuotationAttachmentMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/attachments`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteQuotationAttachment(quotationId: string, attachmentId: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/attachments`, {
method: 'DELETE',
body: JSON.stringify({ id: attachmentId })
});
}
export async function getQuotationRevisions(id: string): Promise<QuotationRevisionsResponse> {
return apiClient<QuotationRevisionsResponse>(`/crm/quotations/${id}/revisions`);
}
export async function createQuotationRevision(id: string, revisionRemark?: string) {
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/revisions`, {
method: 'POST',
body: JSON.stringify({ revisionRemark })
});
}

View File

@@ -0,0 +1,392 @@
export interface QuotationOption {
id: string;
code: string;
label: string;
value: string | null;
}
export interface QuotationBranchOption {
id: string;
code: string;
name: string;
value: string | null;
}
export interface QuotationCustomerLookup {
id: string;
code: string;
name: string;
branchId: string | null;
}
export interface QuotationContactLookup {
id: string;
customerId: string;
name: string;
email: string | null;
mobile: string | null;
isPrimary: boolean;
}
export interface QuotationEnquiryLookup {
id: string;
code: string;
customerId: string;
contactId: string | null;
title: string;
projectName: string | null;
projectLocation: string | null;
branchId: string | null;
}
export interface QuotationSalesmanLookup {
id: string;
name: string;
}
export interface QuotationRecord {
id: string;
organizationId: string;
branchId: string | null;
code: string;
enquiryId: string | null;
customerId: string;
contactId: string | null;
quotationDate: string;
validUntil: string | null;
quotationType: string;
projectName: string | null;
projectLocation: string | null;
attention: string | null;
reference: string | null;
notes: string | null;
status: string;
revision: number;
parentQuotationId: string | null;
revisionRemark: string | null;
currency: string;
exchangeRate: number;
subtotal: number;
discount: number;
discountType: string | null;
taxRate: number;
taxAmount: number;
totalAmount: number;
chancePercent: number | null;
isHotProject: boolean;
competitor: string | null;
salesmanId: string | null;
isSent: boolean;
sentAt: string | null;
sentVia: string | null;
acceptedAt: string | null;
rejectedAt: string | null;
rejectionReason: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface QuotationListItem extends QuotationRecord {
customerName: string;
contactName: string | null;
enquiryCode: string | null;
itemCount: number;
revisionLabel: string;
}
export interface QuotationItemRecord {
id: string;
organizationId: string;
quotationId: string;
itemNumber: number;
productType: string;
description: string;
quantity: number;
unit: string | null;
unitPrice: number;
discount: number;
discountType: string | null;
taxRate: number;
totalPrice: number;
notes: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface QuotationCustomerRecord {
id: string;
organizationId: string;
quotationId: string;
customerId: string;
role: string;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface QuotationCustomerListItem extends QuotationCustomerRecord {
customerName: string;
customerCode: string;
}
export interface QuotationTopicItemRecord {
id: string;
organizationId: string;
topicId: string;
content: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface QuotationTopicRecord {
id: string;
organizationId: string;
quotationId: string;
topicType: string;
title: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
items: QuotationTopicItemRecord[];
}
export interface QuotationFollowupRecord {
id: string;
organizationId: string;
quotationId: string;
followupDate: string;
followupType: string;
contactId: string | null;
outcome: string | null;
notes: string | null;
nextFollowupDate: string | null;
nextAction: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
createdBy: string;
updatedBy: string;
}
export interface QuotationAttachmentRecord {
id: string;
organizationId: string;
quotationId: string;
fileName: string;
originalFileName: string;
filePath: string;
fileSize: number | null;
fileType: string | null;
description: string | null;
uploadedAt: string;
uploadedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface QuotationActivityRecord {
id: string;
action: string;
entityType: string;
entityId: string;
createdAt: string;
userId: string;
actorName: string | null;
}
export interface QuotationRelationItem {
id: string;
code: string;
status: string;
quotationType: string;
totalAmount: number;
updatedAt: string;
}
export interface QuotationReferenceData {
branches: QuotationBranchOption[];
statuses: QuotationOption[];
quotationTypes: QuotationOption[];
currencies: QuotationOption[];
discountTypes: QuotationOption[];
topicTypes: QuotationOption[];
followupTypes: QuotationOption[];
productTypes: QuotationOption[];
customers: QuotationCustomerLookup[];
contacts: QuotationContactLookup[];
enquiries: QuotationEnquiryLookup[];
salesmen: QuotationSalesmanLookup[];
}
export interface QuotationFilters {
page?: number;
limit?: number;
search?: string;
status?: string;
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
hot?: string;
sort?: string;
}
export interface QuotationListResponse {
success: boolean;
time: string;
message: string;
totalItems: number;
offset: number;
limit: number;
items: QuotationListItem[];
}
export interface QuotationDetailResponse {
success: boolean;
time: string;
message: string;
quotation: QuotationRecord;
activity: QuotationActivityRecord[];
}
export interface QuotationItemsResponse {
success: boolean;
time: string;
message: string;
items: QuotationItemRecord[];
}
export interface QuotationCustomersResponse {
success: boolean;
time: string;
message: string;
items: QuotationCustomerListItem[];
}
export interface QuotationTopicsResponse {
success: boolean;
time: string;
message: string;
items: QuotationTopicRecord[];
}
export interface QuotationFollowupsResponse {
success: boolean;
time: string;
message: string;
items: QuotationFollowupRecord[];
}
export interface QuotationAttachmentsResponse {
success: boolean;
time: string;
message: string;
items: QuotationAttachmentRecord[];
}
export interface QuotationRevisionsResponse {
success: boolean;
time: string;
message: string;
items: QuotationRelationItem[];
}
export interface QuotationMutationPayload {
enquiryId?: string | null;
customerId: string;
contactId?: string | null;
quotationDate: string;
validUntil?: string | null;
quotationType: string;
projectName?: string;
projectLocation?: string;
attention?: string;
branchId?: string | null;
currency: string;
exchangeRate?: number | null;
status: string;
chancePercent?: number | null;
isHotProject?: boolean;
competitor?: string;
reference?: string;
notes?: string;
salesmanId?: string | null;
discount?: number | null;
discountType?: string | null;
taxRate?: number | null;
isActive?: boolean;
sentVia?: string | null;
revisionRemark?: string | null;
}
export interface QuotationItemMutationPayload {
productType: string;
description: string;
quantity: number;
unit?: string;
unitPrice: number;
discount?: number | null;
discountType?: string | null;
taxRate?: number | null;
notes?: string;
sortOrder?: number | null;
}
export interface QuotationCustomerMutationPayload {
customerId: string;
role: string;
isPrimary?: boolean;
}
export interface QuotationTopicMutationPayload {
id?: string;
topicType: string;
title: string;
sortOrder?: number | null;
items: Array<{
id?: string;
content: string;
sortOrder?: number | null;
}>;
}
export interface QuotationFollowupMutationPayload {
id?: string;
followupDate: string;
followupType: string;
contactId?: string | null;
outcome?: string;
notes?: string;
nextFollowupDate?: string | null;
nextAction?: string;
}
export interface QuotationAttachmentMutationPayload {
id?: string;
fileName: string;
originalFileName: string;
filePath: string;
fileSize?: number | null;
fileType?: string | null;
description?: string;
}
export interface QuotationRevisionMutationPayload {
revisionRemark?: string;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { AlertModal } from '@/components/modal/alert-modal';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { deleteQuotationMutation } from '../api/mutations';
import type { QuotationListItem, QuotationReferenceData } from '../api/types';
import { QuotationFormSheet } from './quotation-form-sheet';
export function QuotationCellAction({
data,
referenceData,
canUpdate,
canDelete
}: {
data: QuotationListItem;
referenceData: QuotationReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const router = useRouter();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const deleteMutation = useMutation({
...deleteQuotationMutation,
onSuccess: () => {
toast.success('Quotation deleted successfully');
setDeleteOpen(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete quotation')
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<QuotationFormSheet
quotation={data}
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<span className='sr-only'>Open actions</span>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/quotations/${data.id}`)}>
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import Link from 'next/link';
import type { Column, ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import { Icons } from '@/components/icons';
import type { QuotationListItem, QuotationReferenceData } from '../api/types';
import { QuotationCellAction } from './quotation-cell-action';
import { QuotationStatusBadge } from './quotation-status-badge';
export function getQuotationColumns({
referenceData,
canUpdate,
canDelete
}: {
referenceData: QuotationReferenceData;
canUpdate: boolean;
canDelete: boolean;
}): ColumnDef<QuotationListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
const typeMap = new Map(referenceData.quotationTypes.map((item) => [item.id, item]));
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
return [
{
id: 'code',
accessorKey: 'code',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Quotation' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<Link href={`/dashboard/crm/quotations/${row.original.id}`} className='font-medium hover:underline'>
{row.original.code}
</Link>
<span className='text-muted-foreground text-xs'>
{row.original.customerName}
{row.original.projectName ? ` - ${row.original.projectName}` : ''}
</span>
</div>
),
meta: {
label: 'Quotation',
placeholder: 'Search quotations...',
variant: 'text' as const,
icon: Icons.search
},
enableColumnFilter: true
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const status = statusMap.get(row.original.status);
return <QuotationStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
variant: 'multiSelect' as const,
options: referenceData.statuses.map((item) => ({ value: item.id, label: item.label }))
},
enableColumnFilter: true
},
{
id: 'quotationType',
accessorKey: 'quotationType',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Type' />
),
cell: ({ row }) => (
<Badge variant='outline'>
{typeMap.get(row.original.quotationType)?.label ?? 'Unknown'}
</Badge>
),
meta: {
label: 'Type',
variant: 'multiSelect' as const,
options: referenceData.quotationTypes.map((item) => ({ value: item.id, label: item.label }))
},
enableColumnFilter: true
},
{
id: 'branch',
accessorFn: (row) => row.branchId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch' />
),
cell: ({ row }) => (
<span>{row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'}</span>
),
meta: {
label: 'Branch',
variant: 'multiSelect' as const,
options: referenceData.branches.map((item) => ({ value: item.id, label: item.name }))
},
enableColumnFilter: true
},
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' />
),
cell: ({ row }) => row.original.customerName,
meta: {
label: 'Customer',
variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({ value: item.id, label: item.name }))
},
enableColumnFilter: true
},
{
id: 'enquiry',
accessorFn: (row) => row.enquiryId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Enquiry' />
),
cell: ({ row }) => row.original.enquiryCode ?? '-',
meta: {
label: 'Enquiry',
variant: 'multiSelect' as const,
options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
},
enableColumnFilter: true
},
{
id: 'isHotProject',
accessorFn: (row) => String(row.isHotProject),
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Hot' />
),
cell: ({ row }) => (row.original.isHotProject ? <Badge>Hot</Badge> : <span>-</span>),
meta: {
label: 'Hot Project',
variant: 'multiSelect' as const,
options: [
{ value: 'true', label: 'Hot' },
{ value: 'false', label: 'Normal' }
]
},
enableColumnFilter: true
},
{
id: 'totalAmount',
accessorKey: 'totalAmount',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Total' />
),
cell: ({ row }) => row.original.totalAmount.toLocaleString()
},
{
id: 'actions',
cell: ({ row }) => (
<QuotationCellAction
data={row.original}
referenceData={referenceData}
canUpdate={canUpdate}
canDelete={canDelete}
/>
)
}
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,477 @@
'use client';
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
type FormState = {
enquiryId: string;
customerId: string;
contactId: string;
quotationDate: string;
validUntil: string;
quotationType: string;
projectName: string;
projectLocation: string;
attention: string;
branchId: string;
currency: string;
exchangeRate: string;
status: string;
chancePercent: string;
competitor: string;
reference: string;
notes: string;
salesmanId: string;
discount: string;
discountType: string;
taxRate: string;
revisionRemark: string;
isHotProject: boolean;
isActive: boolean;
};
function toFormState(
quotation: QuotationRecord | undefined,
referenceData: QuotationReferenceData
): FormState {
return {
enquiryId: quotation?.enquiryId ?? '',
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: quotation?.contactId ?? '',
quotationDate:
quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
validUntil: quotation?.validUntil?.slice(0, 10) ?? '',
quotationType: quotation?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
projectName: quotation?.projectName ?? '',
projectLocation: quotation?.projectLocation ?? '',
attention: quotation?.attention ?? '',
branchId: quotation?.branchId ?? '',
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
exchangeRate: String(quotation?.exchangeRate ?? 1),
status: quotation?.status ?? referenceData.statuses[0]?.id ?? '',
chancePercent:
quotation?.chancePercent === null || quotation?.chancePercent === undefined
? ''
: String(quotation.chancePercent),
competitor: quotation?.competitor ?? '',
reference: quotation?.reference ?? '',
notes: quotation?.notes ?? '',
salesmanId: quotation?.salesmanId ?? '',
discount: String(quotation?.discount ?? 0),
discountType: quotation?.discountType ?? '',
taxRate: String(quotation?.taxRate ?? 0),
revisionRemark: quotation?.revisionRemark ?? '',
isHotProject: quotation?.isHotProject ?? false,
isActive: quotation?.isActive ?? true
};
}
function Field({
label,
children,
className
}: {
label: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div className={className}>
<div className='mb-2 text-sm font-medium'>{label}</div>
{children}
</div>
);
}
export function QuotationFormSheet({
quotation,
open,
onOpenChange,
referenceData
}: {
quotation?: QuotationRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: QuotationReferenceData;
}) {
const isEdit = !!quotation;
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
const [state, setState] = useState<FormState>(defaultState);
const createMutation = useMutation({
...createQuotationMutation,
onSuccess: () => {
toast.success('Quotation created successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create quotation')
});
const updateMutation = useMutation({
...updateQuotationMutation,
onSuccess: () => {
toast.success('Quotation updated successfully');
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update quotation')
});
useEffect(() => {
if (open) {
setState(defaultState);
}
}, [defaultState, open]);
const contacts = useMemo(
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
[referenceData.contacts, state.customerId]
);
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setState((current) => ({ ...current, [key]: value }));
}
function handleEnquiryChange(value: string) {
const enquiry = referenceData.enquiries.find((item) => item.id === value);
setState((current) => ({
...current,
enquiryId: value === '__none__' ? '' : value,
customerId: enquiry?.customerId ?? current.customerId,
contactId: enquiry?.contactId ?? '',
projectName: enquiry?.projectName ?? current.projectName,
projectLocation: enquiry?.projectLocation ?? current.projectLocation,
branchId: enquiry?.branchId ?? current.branchId
}));
}
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const payload: QuotationMutationPayload = {
enquiryId: state.enquiryId || null,
customerId: state.customerId,
contactId: state.contactId || null,
quotationDate: state.quotationDate,
validUntil: state.validUntil || null,
quotationType: state.quotationType,
projectName: state.projectName,
projectLocation: state.projectLocation,
attention: state.attention,
branchId: state.branchId || null,
currency: state.currency,
exchangeRate: state.exchangeRate ? Number(state.exchangeRate) : 1,
status: state.status,
chancePercent: state.chancePercent ? Number(state.chancePercent) : null,
isHotProject: state.isHotProject,
competitor: state.competitor,
reference: state.reference,
notes: state.notes,
salesmanId: state.salesmanId || null,
discount: state.discount ? Number(state.discount) : 0,
discountType: state.discountType || null,
taxRate: state.taxRate ? Number(state.taxRate) : 0,
revisionRemark: state.revisionRemark || null,
isActive: state.isActive
};
if (isEdit && quotation) {
await updateMutation.mutateAsync({ id: quotation.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-5xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
<SheetDescription>
Capture quotation header data, customer linkage, and commercial context.
</SheetDescription>
</SheetHeader>
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
<div className='grid gap-4 py-4 md:grid-cols-2'>
<Field label='Enquiry'>
<Select value={state.enquiryId || '__none__'} onValueChange={handleEnquiryChange}>
<SelectTrigger>
<SelectValue placeholder='Select enquiry' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No linked enquiry</SelectItem>
{referenceData.enquiries.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.code} - {item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Customer'>
<Select
value={state.customerId}
onValueChange={(value) => {
setField('customerId', value);
setField('contactId', '');
}}
>
<SelectTrigger>
<SelectValue placeholder='Select customer' />
</SelectTrigger>
<SelectContent>
{referenceData.customers.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name} ({item.code})
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Contact'>
<Select
value={state.contactId || '__none__'}
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
>
<SelectTrigger>
<SelectValue placeholder='Select contact' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No contact</SelectItem>
{contacts.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Quotation Type'>
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
<SelectTrigger>
<SelectValue placeholder='Select type' />
</SelectTrigger>
<SelectContent>
{referenceData.quotationTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Quotation Date'>
<Input type='date' value={state.quotationDate} onChange={(e) => setField('quotationDate', e.target.value)} />
</Field>
<Field label='Valid Until'>
<Input type='date' value={state.validUntil} onChange={(e) => setField('validUntil', e.target.value)} />
</Field>
<Field label='Branch'>
<Select value={state.branchId || '__none__'} onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Select branch' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Unassigned</SelectItem>
{referenceData.branches.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Status'>
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
<SelectTrigger>
<SelectValue placeholder='Select status' />
</SelectTrigger>
<SelectContent>
{referenceData.statuses.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Currency'>
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
<SelectTrigger>
<SelectValue placeholder='Select currency' />
</SelectTrigger>
<SelectContent>
{referenceData.currencies.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Exchange Rate'>
<Input value={state.exchangeRate} onChange={(e) => setField('exchangeRate', e.target.value)} type='number' step='0.0001' />
</Field>
<Field label='Project Name'>
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
</Field>
<Field label='Project Location'>
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
</Field>
<Field label='Attention'>
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
</Field>
<Field label='Reference'>
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
</Field>
<Field label='Salesman'>
<Select value={state.salesmanId || '__none__'} onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Select salesman' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Unassigned</SelectItem>
{referenceData.salesmen.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Chance %'>
<Input value={state.chancePercent} onChange={(e) => setField('chancePercent', e.target.value)} type='number' min='0' max='100' />
</Field>
<Field label='Discount'>
<Input value={state.discount} onChange={(e) => setField('discount', e.target.value)} type='number' step='0.01' />
</Field>
<Field label='Discount Type'>
<Select value={state.discountType || '__none__'} onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Select discount type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>None</SelectItem>
{referenceData.discountTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label='Tax Rate %'>
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
</Field>
<Field label='Competitor'>
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
</Field>
<div className='md:col-span-2'>
<Field label='Notes'>
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
</Field>
</div>
<div className='md:col-span-2'>
<Field label='Revision Remark'>
<Textarea value={state.revisionRemark} onChange={(e) => setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' />
</Field>
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Hot Project</div>
<div className='text-muted-foreground text-sm'>Highlight urgent or strategic deals.</div>
</div>
<Switch checked={state.isHotProject} onCheckedChange={(checked) => setField('isHotProject', checked)} />
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Record</div>
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setField('isActive', checked)} />
</div>
</div>
</form>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='quotation-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Quotation' : 'Create Quotation'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function QuotationFormSheetTrigger({
referenceData
}: {
referenceData: QuotationReferenceData;
}) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add Quotation
</Button>
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
</>
);
}

View File

@@ -0,0 +1,48 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { QuotationReferenceData } from '../api/types';
import { quotationsQueryOptions } from '../api/queries';
import { QuotationsTable } from './quotations-table';
export default function QuotationListing({
referenceData,
canUpdate,
canDelete
}: {
referenceData: QuotationReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const page = searchParamsCache.get('page');
const limit = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const quotationType = searchParamsCache.get('quotationType');
const branch = searchParamsCache.get('branch');
const customer = searchParamsCache.get('customer');
const enquiry = searchParamsCache.get('enquiry');
const hot = searchParamsCache.get('hot');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit,
...(search && { search }),
...(status && { status }),
...(quotationType && { quotationType }),
...(branch && { branch }),
...(customer && { customer }),
...(enquiry && { enquiry }),
...(hot && { hot }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(quotationsQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationsTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { Icons } from '@/components/icons';
export function QuotationStatusBadge({ code, label }: { code?: string | null; label: string }) {
const normalized = code?.toLowerCase() ?? '';
const variant =
normalized === 'approved' || normalized === 'accepted' || normalized === 'won'
? 'default'
: normalized === 'draft' || normalized === 'new'
? 'secondary'
: normalized === 'rejected' || normalized === 'lost'
? 'destructive'
: 'outline';
const Icon =
normalized === 'approved' || normalized === 'accepted' || normalized === 'won'
? Icons.circleCheck
: normalized === 'rejected' || normalized === 'lost'
? Icons.warning
: Icons.circle;
return (
<Badge variant={variant} className='capitalize'>
<Icon className='h-3 w-3' />
{label}
</Badge>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useMemo } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useSuspenseQuery } from '@tanstack/react-query';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import { quotationsQueryOptions } from '../api/queries';
import type { QuotationReferenceData } from '../api/types';
import { getQuotationColumns } from './quotation-columns';
export function QuotationsTable({
referenceData,
canUpdate,
canDelete
}: {
referenceData: QuotationReferenceData;
canUpdate: boolean;
canDelete: boolean;
}) {
const columns = useMemo(
() => getQuotationColumns({ referenceData, canUpdate, canDelete }),
[referenceData, canUpdate, canDelete]
);
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
status: parseAsString,
quotationType: parseAsString,
branch: parseAsString,
customer: parseAsString,
enquiry: parseAsString,
hot: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.quotationType && { quotationType: params.quotationType }),
...(params.branch && { branch: params.branch }),
...(params.customer && { customer: params.customer }),
...(params.enquiry && { enquiry: params.enquiry }),
...(params.hot && { hot: params.hot }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(quotationsQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
columns,
pageCount,
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}

View File

@@ -0,0 +1,103 @@
import * as z from 'zod';
export const quotationSchema = z.object({
enquiryId: z.string().optional().nullable(),
customerId: z.string().min(1, 'Please select a customer'),
contactId: z.string().optional().nullable(),
quotationDate: z.string().min(1, 'Quotation date is required'),
validUntil: z.string().optional().nullable(),
quotationType: z.string().min(1, 'Please select a quotation type'),
projectName: z.string().optional(),
projectLocation: z.string().optional(),
attention: z.string().optional(),
branchId: z.string().optional().nullable(),
currency: z.string().min(1, 'Please select a currency'),
exchangeRate: z.number().nullable().optional(),
status: z.string().min(1, 'Please select a status'),
chancePercent: z
.number()
.nullable()
.optional()
.refine(
(value) => value === null || value === undefined || (value >= 0 && value <= 100),
'Chance percent must be between 0 and 100'
),
isHotProject: z.boolean().default(false),
competitor: z.string().optional(),
reference: z.string().optional(),
notes: z.string().optional(),
salesmanId: z.string().optional().nullable(),
discount: z.number().nullable().optional(),
discountType: z.string().optional().nullable(),
taxRate: z.number().nullable().optional(),
isActive: z.boolean().default(true),
sentVia: z.string().optional().nullable(),
revisionRemark: z.string().optional().nullable()
});
export const quotationItemSchema = z.object({
productType: z.string().min(1, 'Please select a product type'),
description: z.string().min(2, 'Description must be at least 2 characters'),
quantity: z.number().positive('Quantity must be greater than 0'),
unit: z.string().optional(),
unitPrice: z.number().min(0, 'Unit price must be 0 or greater'),
discount: z.number().min(0).nullable().optional(),
discountType: z.string().nullable().optional(),
taxRate: z.number().min(0).nullable().optional(),
notes: z.string().optional(),
sortOrder: z.number().nullable().optional()
});
export const quotationCustomerSchema = z.object({
customerId: z.string().min(1, 'Please select a customer'),
role: z.string().min(1, 'Please select a role'),
isPrimary: z.boolean().default(false)
});
export const quotationTopicSchema = z.object({
id: z.string().optional(),
topicType: z.string().min(1, 'Please select a topic type'),
title: z.string().min(2, 'Title must be at least 2 characters'),
sortOrder: z.number().nullable().optional(),
items: z
.array(
z.object({
id: z.string().optional(),
content: z.string().min(1, 'Topic item content is required'),
sortOrder: z.number().nullable().optional()
})
)
.min(1, 'At least one topic item is required')
});
export const quotationFollowupSchema = z.object({
id: z.string().optional(),
followupDate: z.string().min(1, 'Follow-up date is required'),
followupType: z.string().min(1, 'Please select a follow-up type'),
contactId: z.string().optional().nullable(),
outcome: z.string().optional(),
notes: z.string().optional(),
nextFollowupDate: z.string().optional().nullable(),
nextAction: z.string().optional()
});
export const quotationAttachmentSchema = z.object({
id: z.string().optional(),
fileName: z.string().min(1, 'Stored file name is required'),
originalFileName: z.string().min(1, 'Original file name is required'),
filePath: z.string().min(1, 'File path is required'),
fileSize: z.number().nullable().optional(),
fileType: z.string().nullable().optional(),
description: z.string().optional()
});
export const quotationRevisionSchema = z.object({
revisionRemark: z.string().optional()
});
export type QuotationFormValues = z.input<typeof quotationSchema>;
export type QuotationItemFormValues = z.input<typeof quotationItemSchema>;
export type QuotationCustomerFormValues = z.input<typeof quotationCustomerSchema>;
export type QuotationTopicFormValues = z.input<typeof quotationTopicSchema>;
export type QuotationFollowupFormValues = z.input<typeof quotationFollowupSchema>;
export type QuotationAttachmentFormValues = z.input<typeof quotationAttachmentSchema>;

File diff suppressed because it is too large Load Diff