task-e
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
258
src/features/crm/quotations/api/mutations.ts
Normal file
258
src/features/crm/quotations/api/mutations.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
72
src/features/crm/quotations/api/queries.ts
Normal file
72
src/features/crm/quotations/api/queries.ts
Normal 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)
|
||||
});
|
||||
214
src/features/crm/quotations/api/service.ts
Normal file
214
src/features/crm/quotations/api/service.ts
Normal 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 })
|
||||
});
|
||||
}
|
||||
392
src/features/crm/quotations/api/types.ts
Normal file
392
src/features/crm/quotations/api/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
src/features/crm/quotations/components/quotation-columns.tsx
Normal file
167
src/features/crm/quotations/components/quotation-columns.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
1202
src/features/crm/quotations/components/quotation-detail.tsx
Normal file
1202
src/features/crm/quotations/components/quotation-detail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
477
src/features/crm/quotations/components/quotation-form-sheet.tsx
Normal file
477
src/features/crm/quotations/components/quotation-form-sheet.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
src/features/crm/quotations/components/quotation-listing.tsx
Normal file
48
src/features/crm/quotations/components/quotation-listing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
72
src/features/crm/quotations/components/quotations-table.tsx
Normal file
72
src/features/crm/quotations/components/quotations-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/features/crm/quotations/schemas/quotation.schema.ts
Normal file
103
src/features/crm/quotations/schemas/quotation.schema.ts
Normal 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>;
|
||||
1850
src/features/crm/quotations/server/service.ts
Normal file
1850
src/features/crm/quotations/server/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user