task-b
task-b.1 complere
This commit is contained in:
@@ -1,78 +0,0 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
approveQuotation,
|
||||
convertEnquiryToQuotation,
|
||||
createCustomer,
|
||||
createQuotationRevision,
|
||||
markQuotationStatus,
|
||||
rejectQuotation,
|
||||
revokeContactShare,
|
||||
shareContact,
|
||||
submitQuotationApproval,
|
||||
updateCustomer
|
||||
} from './service';
|
||||
import { crmKeys } from './queries';
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ContactSharePayload,
|
||||
CustomerMutationPayload,
|
||||
Quotation
|
||||
} from './types';
|
||||
|
||||
function invalidateCrm() {
|
||||
return getQueryClient().invalidateQueries({ queryKey: crmKeys.all });
|
||||
}
|
||||
|
||||
export const createCustomerMutation = mutationOptions({
|
||||
mutationFn: (payload: CustomerMutationPayload) => createCustomer(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const updateCustomerMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: CustomerMutationPayload }) =>
|
||||
updateCustomer(id, values),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const convertEnquiryMutation = mutationOptions({
|
||||
mutationFn: (enquiryId: string) => convertEnquiryToQuotation(enquiryId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const submitApprovalMutation = mutationOptions({
|
||||
mutationFn: (quotationId: string) => submitQuotationApproval(quotationId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const approveQuotationMutation = mutationOptions({
|
||||
mutationFn: (payload: ApprovalActionPayload) => approveQuotation(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const rejectQuotationMutation = mutationOptions({
|
||||
mutationFn: (payload: ApprovalActionPayload) => rejectQuotation(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const createQuotationRevisionMutation = mutationOptions({
|
||||
mutationFn: (quotationId: string) => createQuotationRevision(quotationId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const markQuotationStatusMutation = mutationOptions({
|
||||
mutationFn: ({ quotationId, status }: { quotationId: string; status: Quotation['status'] }) =>
|
||||
markQuotationStatus(quotationId, status),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const shareContactMutation = mutationOptions({
|
||||
mutationFn: (payload: ContactSharePayload) => shareContact(payload),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
|
||||
export const revokeContactShareMutation = mutationOptions({
|
||||
mutationFn: ({ contactId, shareId }: { contactId: string; shareId: string }) =>
|
||||
revokeContactShare(contactId, shareId),
|
||||
onSuccess: invalidateCrm
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getApprovals,
|
||||
getCrmDashboardData,
|
||||
getCrmReferenceData,
|
||||
getCustomerById,
|
||||
getCustomers,
|
||||
getEnquiries,
|
||||
getEnquiryById,
|
||||
getQuotationById,
|
||||
getQuotations
|
||||
} from './service';
|
||||
import type {
|
||||
ApprovalFilters,
|
||||
CustomerFilters,
|
||||
EnquiryFilters,
|
||||
QuotationFilters
|
||||
} from './types';
|
||||
|
||||
export const crmKeys = {
|
||||
all: ['crm'] as const,
|
||||
references: () => [...crmKeys.all, 'references'] as const,
|
||||
dashboard: () => [...crmKeys.all, 'dashboard'] as const,
|
||||
customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const,
|
||||
customer: (id: string) => [...crmKeys.all, 'customer', id] as const,
|
||||
enquiries: (filters: EnquiryFilters) => [...crmKeys.all, 'enquiries', filters] as const,
|
||||
enquiry: (id: string) => [...crmKeys.all, 'enquiry', id] as const,
|
||||
quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const,
|
||||
quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const,
|
||||
approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const
|
||||
};
|
||||
|
||||
export const crmReferenceQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.references(),
|
||||
queryFn: getCrmReferenceData
|
||||
});
|
||||
|
||||
export const crmDashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.dashboard(),
|
||||
queryFn: getCrmDashboardData
|
||||
});
|
||||
|
||||
export const customersQueryOptions = (filters: CustomerFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.customers(filters),
|
||||
queryFn: () => getCustomers(filters)
|
||||
});
|
||||
|
||||
export const customerByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.customer(id),
|
||||
queryFn: () => getCustomerById(id)
|
||||
});
|
||||
|
||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.enquiries(filters),
|
||||
queryFn: () => getEnquiries(filters)
|
||||
});
|
||||
|
||||
export const enquiryByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.enquiry(id),
|
||||
queryFn: () => getEnquiryById(id)
|
||||
});
|
||||
|
||||
export const quotationsQueryOptions = (filters: QuotationFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.quotations(filters),
|
||||
queryFn: () => getQuotations(filters)
|
||||
});
|
||||
|
||||
export const quotationByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.quotation(id),
|
||||
queryFn: () => getQuotationById(id)
|
||||
});
|
||||
|
||||
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmKeys.approvals(filters),
|
||||
queryFn: () => getApprovals(filters)
|
||||
});
|
||||
@@ -1,474 +0,0 @@
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ApprovalFilters,
|
||||
ContactSharePayload,
|
||||
CrmDashboardData,
|
||||
CrmReferenceData,
|
||||
Customer,
|
||||
CustomerDetailResponse,
|
||||
CustomerFilters,
|
||||
CustomerMutationPayload,
|
||||
Enquiry,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
PaginatedResponse,
|
||||
Quotation,
|
||||
QuotationDetailResponse,
|
||||
QuotationFilters
|
||||
} from './types';
|
||||
import { initialCrmState, type CrmMockState } from '../data/mock-crm';
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
let crmState: CrmMockState = clone(initialCrmState);
|
||||
|
||||
function sortItems<T>(items: T[], sort?: string) {
|
||||
if (!sort) return items;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) return items;
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const av = String((a as Record<string, unknown>)[rule.id] ?? '');
|
||||
const bv = String((b as Record<string, unknown>)[rule.id] ?? '');
|
||||
return rule.desc ? bv.localeCompare(av) : av.localeCompare(bv);
|
||||
});
|
||||
} catch {
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
function paginate<T>(items: T[], page = 1, limit = 10): PaginatedResponse<T> {
|
||||
const start = (page - 1) * limit;
|
||||
return {
|
||||
items: items.slice(start, start + limit),
|
||||
totalItems: items.length
|
||||
};
|
||||
}
|
||||
|
||||
function findBranch(branchId: string) {
|
||||
return crmState.branches.find((branch) => branch.id === branchId);
|
||||
}
|
||||
|
||||
function findSalesperson(id: string) {
|
||||
return crmState.salespersons.find((sales) => sales.id === id);
|
||||
}
|
||||
|
||||
export async function getCrmReferenceData(): Promise<CrmReferenceData> {
|
||||
return clone({
|
||||
branches: crmState.branches,
|
||||
salespersons: crmState.salespersons,
|
||||
masterOptions: crmState.masterOptions,
|
||||
sequences: crmState.sequences,
|
||||
templates: crmState.templates
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
||||
const openEnquiries = crmState.enquiries.filter((enquiry) =>
|
||||
['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status)
|
||||
);
|
||||
const pendingQuotations = crmState.quotations.filter(
|
||||
(quotation) => quotation.status === 'pending_approval'
|
||||
);
|
||||
const wonQuotations = crmState.quotations.filter((quotation) => quotation.status === 'accepted');
|
||||
const lostQuotations = crmState.quotations.filter((quotation) => quotation.status === 'lost');
|
||||
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
|
||||
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
|
||||
const conversionRate = Math.round(
|
||||
(wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100
|
||||
);
|
||||
|
||||
return clone({
|
||||
metrics: [
|
||||
{ id: 'm-1', label: 'Total Enquiries', value: String(crmState.enquiries.length), change: '+12%', trend: 'up' },
|
||||
{ id: 'm-2', label: 'Open Enquiries', value: String(openEnquiries.length), change: '+3', trend: 'up' },
|
||||
{ id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' },
|
||||
{ id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' },
|
||||
{ id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' },
|
||||
{ id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' },
|
||||
{ id: 'm-7', label: 'Conversion Rate', value: `${conversionRate}%`, change: '+4pts', trend: 'up' },
|
||||
{ id: 'm-8', label: 'Hot Projects', value: String(hotQuotations.length), change: 'Focus', trend: 'flat' },
|
||||
{ id: 'm-9', label: 'Follow-up Due Today', value: String(dueFollowUps.length), change: 'Today', trend: 'flat' }
|
||||
],
|
||||
enquiryPipeline: [
|
||||
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
|
||||
{ label: 'Qualifying', value: crmState.enquiries.filter((item) => item.status === 'qualifying').length },
|
||||
{ label: 'Requirement', value: crmState.enquiries.filter((item) => item.status === 'requirement').length },
|
||||
{ label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length },
|
||||
{ label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').length }
|
||||
],
|
||||
quotationPipeline: [
|
||||
{ label: 'Draft', value: crmState.quotations.filter((item) => item.status === 'draft').length },
|
||||
{ label: 'Pending', value: crmState.quotations.filter((item) => item.status === 'pending_approval').length },
|
||||
{ label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length },
|
||||
{ label: 'Accepted', value: crmState.quotations.filter((item) => item.status === 'accepted').length },
|
||||
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
|
||||
],
|
||||
salesPerformance: crmState.salespersons.map((salesperson) => ({
|
||||
label: salesperson.nickname,
|
||||
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id).length,
|
||||
secondaryValue: crmState.quotations
|
||||
.filter((quotation) => quotation.salesmanId === salesperson.id)
|
||||
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
|
||||
})),
|
||||
competitorAnalysis: [
|
||||
{ label: 'Apex Dock', value: 3 },
|
||||
{ label: 'LiftPro', value: 2 },
|
||||
{ label: 'SunNorth', value: 1 },
|
||||
{ label: 'Others', value: 2 }
|
||||
],
|
||||
wonLostTrend: [
|
||||
{ label: 'Jan', value: 1, secondaryValue: 0 },
|
||||
{ label: 'Feb', value: 2, secondaryValue: 1 },
|
||||
{ label: 'Mar', value: 1, secondaryValue: 1 },
|
||||
{ label: 'Apr', value: 3, secondaryValue: 1 },
|
||||
{ label: 'May', value: 2, secondaryValue: 2 },
|
||||
{ label: 'Jun', value: 2, secondaryValue: 1 }
|
||||
],
|
||||
pendingApprovals: crmState.approvals.filter((item) => item.status === 'pending'),
|
||||
hotQuotations,
|
||||
dueFollowUps
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomers(filters: CustomerFilters): Promise<PaginatedResponse<Customer>> {
|
||||
let items = [...crmState.customers];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(customer) =>
|
||||
customer.name.toLowerCase().includes(search) || customer.code.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
items = items.filter((customer) => customer.customerStatus === filters.status);
|
||||
}
|
||||
|
||||
if (filters.branch) {
|
||||
items = items.filter((customer) => customer.branchId === filters.branch);
|
||||
}
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: string): Promise<CustomerDetailResponse> {
|
||||
const customer = crmState.customers.find((item) => item.id === id);
|
||||
if (!customer) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
return clone({
|
||||
customer,
|
||||
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
|
||||
shares: crmState.contactShares.filter((share) =>
|
||||
crmState.contacts.some((contact) => contact.id === share.contactId && contact.customerId === id)
|
||||
),
|
||||
shareLogs: crmState.contactShareLogs.filter((log) =>
|
||||
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
||||
),
|
||||
enquiries: crmState.enquiries.filter((enquiry) => enquiry.customerId === id),
|
||||
quotations: crmState.quotations.filter((quotation) =>
|
||||
quotation.customerRoles.some((role) => role.customerId === id)
|
||||
),
|
||||
auditEvents: crmState.auditEvents.filter((event) => event.entityId === id),
|
||||
branch: findBranch(customer.branchId)!
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedResponse<Enquiry>> {
|
||||
let items = [...crmState.enquiries];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) => item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
||||
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
|
||||
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
|
||||
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
|
||||
if (filters.dateTo) items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === id);
|
||||
if (!enquiry) {
|
||||
throw new Error('Enquiry not found');
|
||||
}
|
||||
|
||||
const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!;
|
||||
const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!;
|
||||
return clone({
|
||||
enquiry,
|
||||
customer,
|
||||
contact,
|
||||
quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id),
|
||||
branch: findBranch(enquiry.branchId)!,
|
||||
salesman: findSalesperson(enquiry.salesmanId)!
|
||||
});
|
||||
}
|
||||
|
||||
export async function getQuotations(
|
||||
filters: QuotationFilters
|
||||
): Promise<PaginatedResponse<Quotation>> {
|
||||
let items = [...crmState.quotations];
|
||||
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(quotation) =>
|
||||
quotation.code.toLowerCase().includes(search) ||
|
||||
quotation.project.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.status) items = items.filter((quotation) => quotation.status === filters.status);
|
||||
if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
|
||||
if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
|
||||
if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject));
|
||||
if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
|
||||
if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
|
||||
|
||||
items = sortItems(items, filters.sort);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function getQuotationById(id: string): Promise<QuotationDetailResponse> {
|
||||
const quotation = crmState.quotations.find((item) => item.id === id);
|
||||
if (!quotation) {
|
||||
throw new Error('Quotation not found');
|
||||
}
|
||||
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
|
||||
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
|
||||
const contactIds = [...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))] as string[];
|
||||
|
||||
return clone({
|
||||
quotation,
|
||||
enquiry,
|
||||
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
|
||||
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
|
||||
branch: findBranch(quotation.branchId)!,
|
||||
salesman: findSalesperson(quotation.salesmanId)!,
|
||||
saleAdmin: findSalesperson(quotation.saleAdminId)!,
|
||||
auditEvents: crmState.auditEvents.filter((event) => quotation.auditEventIds.includes(event.id))
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApprovals(filters: ApprovalFilters) {
|
||||
let items = [...crmState.approvals];
|
||||
if (filters.branch) items = items.filter((item) => item.branchId === filters.branch);
|
||||
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
||||
return clone(paginate(items, filters.page, filters.limit));
|
||||
}
|
||||
|
||||
export async function createCustomer(values: CustomerMutationPayload) {
|
||||
const customer: Customer = { id: `cus-${Date.now()}`, contactIds: [], ...values };
|
||||
crmState.customers.unshift(customer);
|
||||
return clone(customer);
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, values: CustomerMutationPayload) {
|
||||
crmState.customers = crmState.customers.map((customer) =>
|
||||
customer.id === id ? { ...customer, ...values } : customer
|
||||
);
|
||||
return getCustomerById(id);
|
||||
}
|
||||
|
||||
export async function convertEnquiryToQuotation(enquiryId: string) {
|
||||
const enquiry = crmState.enquiries.find((item) => item.id === enquiryId);
|
||||
if (!enquiry) throw new Error('Enquiry not found');
|
||||
|
||||
const nextId = `qt-${Date.now()}`;
|
||||
const newQuotation: Quotation = {
|
||||
id: nextId,
|
||||
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
|
||||
enquiryId,
|
||||
quotationDate: '2026-06-11',
|
||||
validUntil: '2026-07-11',
|
||||
quotationType: 'official',
|
||||
project: enquiry.title,
|
||||
siteLocation: enquiry.projectLocation,
|
||||
customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }],
|
||||
salesmanId: enquiry.salesmanId,
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: enquiry.branchId,
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: enquiry.expectedValue,
|
||||
chancePercent: enquiry.chancePercent,
|
||||
isHotProject: enquiry.chancePercent >= 70,
|
||||
items: [{ id: `${nextId}-item-1`, topic: 'Scope', description: enquiry.requirementSummary, quantity: 1, unit: 'lot', unitPrice: enquiry.expectedValue, amount: enquiry.expectedValue }],
|
||||
topics: [
|
||||
{ id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', items: [enquiry.requirementSummary] },
|
||||
{ id: `${nextId}-topic-2`, type: 'exclusion', label: 'Exclusion', items: ['To be confirmed'] },
|
||||
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
};
|
||||
|
||||
crmState.quotations.unshift(newQuotation);
|
||||
crmState.enquiries = crmState.enquiries.map((item) =>
|
||||
item.id === enquiryId
|
||||
? {
|
||||
...item,
|
||||
status: 'converted',
|
||||
quotationIds: [...item.quotationIds, nextId],
|
||||
activities: [
|
||||
...item.activities,
|
||||
{
|
||||
id: `${enquiryId}-activity-${Date.now()}`,
|
||||
type: 'CONVERT',
|
||||
title: 'Convert to quotation',
|
||||
detail: `สร้าง ${newQuotation.code}`,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T10:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
: item
|
||||
);
|
||||
|
||||
return clone(newQuotation);
|
||||
}
|
||||
|
||||
export async function submitQuotationApproval(quotationId: string) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === quotationId
|
||||
? {
|
||||
...quotation,
|
||||
status: 'pending_approval',
|
||||
approvalSteps:
|
||||
quotation.approvalSteps.length > 0
|
||||
? quotation.approvalSteps
|
||||
: [
|
||||
{ id: `${quotationId}-ap-1`, level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }
|
||||
]
|
||||
}
|
||||
: quotation
|
||||
);
|
||||
}
|
||||
|
||||
export async function approveQuotation(payload: ApprovalActionPayload) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) => {
|
||||
if (quotation.id !== payload.quotationId) return quotation;
|
||||
|
||||
const nextSteps = quotation.approvalSteps.map((step, index) =>
|
||||
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||
? {
|
||||
...step,
|
||||
status: 'approved',
|
||||
actedAt: '2026-06-11T11:00:00.000Z',
|
||||
comment: payload.comment
|
||||
}
|
||||
: step
|
||||
);
|
||||
|
||||
const hasPending = nextSteps.some((step) => step.status === 'pending');
|
||||
return {
|
||||
...quotation,
|
||||
approvalSteps: nextSteps,
|
||||
status: hasPending ? 'pending_approval' : 'approved',
|
||||
approvedPdfUrl: hasPending ? quotation.approvedPdfUrl : `/mock/${quotation.code.toLowerCase()}.pdf`
|
||||
};
|
||||
});
|
||||
|
||||
crmState.approvals = crmState.approvals.map((item) =>
|
||||
item.quotationId === payload.quotationId ? { ...item, status: 'approved' } : item
|
||||
);
|
||||
}
|
||||
|
||||
export async function rejectQuotation(payload: ApprovalActionPayload) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === payload.quotationId
|
||||
? {
|
||||
...quotation,
|
||||
status: 'rejected',
|
||||
approvalSteps: quotation.approvalSteps.map((step, index) =>
|
||||
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||
? {
|
||||
...step,
|
||||
status: 'rejected',
|
||||
actedAt: '2026-06-11T11:30:00.000Z',
|
||||
comment: payload.comment
|
||||
}
|
||||
: step
|
||||
)
|
||||
}
|
||||
: quotation
|
||||
);
|
||||
crmState.approvals = crmState.approvals.map((item) =>
|
||||
item.quotationId === payload.quotationId ? { ...item, status: 'rejected' } : item
|
||||
);
|
||||
}
|
||||
|
||||
export async function createQuotationRevision(quotationId: string) {
|
||||
const quotation = crmState.quotations.find((item) => item.id === quotationId);
|
||||
if (!quotation) throw new Error('Quotation not found');
|
||||
|
||||
const revisionNumber = quotation.revision + 1;
|
||||
const newQuotation: Quotation = {
|
||||
...clone(quotation),
|
||||
id: `${quotationId}-rev-${revisionNumber}`,
|
||||
code: `${quotation.code}-R${String(revisionNumber).padStart(2, '0')}`,
|
||||
revision: revisionNumber,
|
||||
status: 'revised',
|
||||
totalAmount: quotation.totalAmount + 125000
|
||||
};
|
||||
|
||||
crmState.quotations.unshift(newQuotation);
|
||||
return clone(newQuotation);
|
||||
}
|
||||
|
||||
export async function markQuotationStatus(quotationId: string, status: Quotation['status']) {
|
||||
crmState.quotations = crmState.quotations.map((quotation) =>
|
||||
quotation.id === quotationId ? { ...quotation, status } : quotation
|
||||
);
|
||||
}
|
||||
|
||||
export async function shareContact(payload: ContactSharePayload) {
|
||||
const share = {
|
||||
id: `share-${Date.now()}`,
|
||||
createdAt: '2026-06-11T12:00:00.000Z',
|
||||
...payload
|
||||
};
|
||||
crmState.contactShares.unshift(share);
|
||||
crmState.contactShareLogs.unshift({
|
||||
id: `share-log-${Date.now()}`,
|
||||
contactId: payload.contactId,
|
||||
action: 'SHARE',
|
||||
targetUserName: payload.sharedWithUserName,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T12:00:00.000Z'
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeContactShare(contactId: string, shareId: string) {
|
||||
const current = crmState.contactShares.find((share) => share.id === shareId);
|
||||
crmState.contactShares = crmState.contactShares.filter((share) => share.id !== shareId);
|
||||
if (current) {
|
||||
crmState.contactShareLogs.unshift({
|
||||
id: `share-log-${Date.now()}`,
|
||||
contactId,
|
||||
action: 'REVOKE',
|
||||
targetUserName: current.sharedWithUserName,
|
||||
actorName: 'CRM Demo',
|
||||
createdAt: '2026-06-11T12:30:00.000Z'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
export type CrmStatusTone = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
export interface CrmBranch {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface CrmSalesperson {
|
||||
id: string;
|
||||
name: string;
|
||||
nickname: string;
|
||||
branchId: string;
|
||||
role: 'sales' | 'sale_admin' | 'manager';
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
position: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
lineId?: string;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
export interface ContactShareLog {
|
||||
id: string;
|
||||
contactId: string;
|
||||
action: 'SHARE' | 'REVOKE';
|
||||
targetUserName: string;
|
||||
actorName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ContactShare {
|
||||
id: string;
|
||||
contactId: string;
|
||||
sharedWithUserId: string;
|
||||
sharedWithUserName: string;
|
||||
permission: 'view' | 'edit';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
taxId: string;
|
||||
address: string;
|
||||
province: string;
|
||||
district: string;
|
||||
subDistrict: string;
|
||||
postalCode: string;
|
||||
phone: string;
|
||||
fax?: string;
|
||||
email: string;
|
||||
customerType: 'developer' | 'contractor' | 'owner' | 'consultant';
|
||||
customerStatus: 'active' | 'prospect' | 'inactive';
|
||||
branchId: string;
|
||||
contactIds: string[];
|
||||
}
|
||||
|
||||
export type EnquiryStatus =
|
||||
| 'new'
|
||||
| 'qualifying'
|
||||
| 'requirement'
|
||||
| 'follow_up'
|
||||
| 'converted'
|
||||
| 'closed_lost'
|
||||
| 'cancelled';
|
||||
|
||||
export interface EnquiryActivity {
|
||||
id: string;
|
||||
type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT';
|
||||
title: string;
|
||||
detail: string;
|
||||
actorName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Enquiry {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
customerId: string;
|
||||
contactId: string;
|
||||
branchId: string;
|
||||
salesmanId: string;
|
||||
productType: 'crane' | 'dockdoor' | 'solarcell';
|
||||
status: EnquiryStatus;
|
||||
requirementSummary: string;
|
||||
projectLocation: string;
|
||||
chancePercent: number;
|
||||
competitor: string;
|
||||
expectedValue: number;
|
||||
dueDate: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
quotationIds: string[];
|
||||
activities: EnquiryActivity[];
|
||||
}
|
||||
|
||||
export type QuotationStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'sent'
|
||||
| 'accepted'
|
||||
| 'lost'
|
||||
| 'cancelled'
|
||||
| 'revised';
|
||||
|
||||
export interface QuotationCustomerRole {
|
||||
role: 'owner' | 'consultant' | 'contractor' | 'billing';
|
||||
customerId: string;
|
||||
contactId?: string;
|
||||
}
|
||||
|
||||
export interface QuotationItem {
|
||||
id: string;
|
||||
topic: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface QuotationTopic {
|
||||
id: string;
|
||||
type: 'scope' | 'exclusion' | 'payment';
|
||||
label: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface QuotationFollowUp {
|
||||
id: string;
|
||||
title: string;
|
||||
dueDate: string;
|
||||
ownerName: string;
|
||||
status: 'open' | 'done';
|
||||
}
|
||||
|
||||
export interface QuotationAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileType: 'pdf' | 'doc' | 'xls' | 'zip';
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalStep {
|
||||
id: string;
|
||||
level: number;
|
||||
approverName: string;
|
||||
approverPosition: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
actedAt?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface Quotation {
|
||||
id: string;
|
||||
code: string;
|
||||
enquiryId: string;
|
||||
quotationDate: string;
|
||||
validUntil: string;
|
||||
quotationType: 'budgetary' | 'official' | 'service';
|
||||
project: string;
|
||||
siteLocation: string;
|
||||
customerRoles: QuotationCustomerRole[];
|
||||
salesmanId: string;
|
||||
saleAdminId: string;
|
||||
branchId: string;
|
||||
status: QuotationStatus;
|
||||
revision: number;
|
||||
totalAmount: number;
|
||||
chancePercent: number;
|
||||
isHotProject: boolean;
|
||||
approvedPdfUrl?: string;
|
||||
approvalSnapshot?: string;
|
||||
items: QuotationItem[];
|
||||
topics: QuotationTopic[];
|
||||
followUps: QuotationFollowUp[];
|
||||
attachments: QuotationAttachment[];
|
||||
approvalSteps: ApprovalStep[];
|
||||
auditEventIds: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
id: string;
|
||||
quotationId: string;
|
||||
quotationCode: string;
|
||||
amount: number;
|
||||
productType: string;
|
||||
branchId: string;
|
||||
submitterName: string;
|
||||
approverLevel: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
entityType: 'customer' | 'enquiry' | 'quotation' | 'approval';
|
||||
entityId: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE';
|
||||
actorName: string;
|
||||
detail: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequence {
|
||||
id: string;
|
||||
documentType: 'enquiry' | 'quotation' | 'quotation_revision';
|
||||
prefix: string;
|
||||
period: string;
|
||||
branchId: string;
|
||||
currentNumber: number;
|
||||
paddingLength: number;
|
||||
}
|
||||
|
||||
export interface MasterOption {
|
||||
id: string;
|
||||
group: 'status' | 'product_type' | 'payment_term' | 'currency' | 'tax_rate';
|
||||
code: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TemplateMapping {
|
||||
id: string;
|
||||
key: string;
|
||||
placeholder: string;
|
||||
sourceField: string;
|
||||
}
|
||||
|
||||
export interface QuotationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
branchId?: string;
|
||||
description: string;
|
||||
mappings: TemplateMapping[];
|
||||
}
|
||||
|
||||
export interface CrmDashboardMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
tone?: CrmStatusTone;
|
||||
}
|
||||
|
||||
export interface CrmChartDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
secondaryValue?: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardData {
|
||||
metrics: CrmDashboardMetric[];
|
||||
enquiryPipeline: CrmChartDatum[];
|
||||
quotationPipeline: CrmChartDatum[];
|
||||
salesPerformance: CrmChartDatum[];
|
||||
competitorAnalysis: CrmChartDatum[];
|
||||
wonLostTrend: CrmChartDatum[];
|
||||
pendingApprovals: ApprovalItem[];
|
||||
hotQuotations: Quotation[];
|
||||
dueFollowUps: QuotationFollowUp[];
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
branch?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
salesman?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface QuotationFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
quotationType?: string;
|
||||
salesman?: string;
|
||||
hot?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
branch?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
totalItems: number;
|
||||
summary?: {
|
||||
activeFilters: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CustomerDetailResponse {
|
||||
customer: Customer;
|
||||
contacts: CustomerContact[];
|
||||
shares: ContactShare[];
|
||||
shareLogs: ContactShareLog[];
|
||||
enquiries: Enquiry[];
|
||||
quotations: Quotation[];
|
||||
auditEvents: AuditEvent[];
|
||||
branch: CrmBranch;
|
||||
}
|
||||
|
||||
export interface EnquiryDetailResponse {
|
||||
enquiry: Enquiry;
|
||||
customer: Customer;
|
||||
contact: CustomerContact;
|
||||
quotations: Quotation[];
|
||||
branch: CrmBranch;
|
||||
salesman: CrmSalesperson;
|
||||
}
|
||||
|
||||
export interface QuotationDetailResponse {
|
||||
quotation: Quotation;
|
||||
enquiry: Enquiry;
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
branch: CrmBranch;
|
||||
salesman: CrmSalesperson;
|
||||
saleAdmin: CrmSalesperson;
|
||||
auditEvents: AuditEvent[];
|
||||
}
|
||||
|
||||
export interface CrmReferenceData {
|
||||
branches: CrmBranch[];
|
||||
salespersons: CrmSalesperson[];
|
||||
masterOptions: MasterOption[];
|
||||
sequences: DocumentSequence[];
|
||||
templates: QuotationTemplate[];
|
||||
}
|
||||
|
||||
export interface CustomerMutationPayload {
|
||||
id?: string;
|
||||
code: string;
|
||||
name: string;
|
||||
taxId: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
customerType: Customer['customerType'];
|
||||
customerStatus: Customer['customerStatus'];
|
||||
branchId: string;
|
||||
address: string;
|
||||
province: string;
|
||||
district: string;
|
||||
subDistrict: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface ApprovalActionPayload {
|
||||
quotationId: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ContactSharePayload {
|
||||
contactId: string;
|
||||
sharedWithUserId: string;
|
||||
sharedWithUserName: string;
|
||||
permission: 'view' | 'edit';
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { approvalsQueryOptions, crmReferenceQueryOptions } from '../api/queries';
|
||||
import { approveQuotationMutation, rejectQuotationMutation } from '../api/mutations';
|
||||
import { BranchBadge, SectionCard, TimelineList } from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
function DecisionDialog({
|
||||
label,
|
||||
description,
|
||||
isPending,
|
||||
onConfirm
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
isPending: boolean;
|
||||
onConfirm: (comment: string) => void;
|
||||
}) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalsPage() {
|
||||
const { data } = useSuspenseQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
const baseApproveMutation = approveQuotationMutation;
|
||||
const baseRejectMutation = rejectQuotationMutation;
|
||||
|
||||
const approveMutation = useMutation({
|
||||
...baseApproveMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseApproveMutation.onSuccess?.(...args);
|
||||
toast.success('Approve mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
...baseRejectMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRejectMutation.onSuccess?.(...args);
|
||||
toast.success('Reject mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard
|
||||
title='Pending Approval List'
|
||||
description='Sequential approval MVP'
|
||||
className='xl:col-span-7'
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
{data.items.map((item) => {
|
||||
const branch = reference.branches.find((value) => value.id === item.branchId);
|
||||
return (
|
||||
<div key={item.id} className='rounded-xl border bg-muted/10 p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='font-semibold'>{item.quotationCode}</p>
|
||||
{branch ? <BranchBadge branch={branch} /> : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{item.productType} / Submitter: {item.submitterName}
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-medium'>
|
||||
Level {item.approverLevel} / {formatCurrency(item.amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<DecisionDialog
|
||||
label='Approve'
|
||||
description='อนุมัติ quotation mock นี้'
|
||||
isPending={approveMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
approveMutation.mutate({ quotationId: item.quotationId, comment })
|
||||
}
|
||||
/>
|
||||
<DecisionDialog
|
||||
label='Reject'
|
||||
description='Reject quotation mock นี้'
|
||||
isPending={rejectMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
rejectMutation.mutate({ quotationId: item.quotationId, comment })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title='Approval Timeline'
|
||||
description='ภาพรวมเส้นทางการอนุมัติ'
|
||||
className='xl:col-span-5'
|
||||
>
|
||||
<TimelineList
|
||||
items={data.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: `${item.quotationCode} / Level ${item.approverLevel}`,
|
||||
detail: `${item.submitterName} ส่งอนุมัติสำหรับ ${item.productType}`,
|
||||
timestamp: '2026-06-11'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import { crmDashboardQueryOptions } from '../api/queries';
|
||||
import { KpiCard, QuotationStatusBadge, RelatedQuotationList, SectionCard } from './shared';
|
||||
import { formatCurrency, formatDate } from '../utils/format';
|
||||
|
||||
const chartConfig = {
|
||||
primary: { label: 'Primary', color: 'var(--chart-1)' },
|
||||
secondary: { label: 'Secondary', color: 'var(--chart-2)' },
|
||||
tertiary: { label: 'Tertiary', color: 'var(--chart-3)' }
|
||||
};
|
||||
|
||||
export function CrmDashboardPage() {
|
||||
const { data } = useSuspenseQuery(crmDashboardQueryOptions());
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5'>
|
||||
{data.metrics.map((metric) => (
|
||||
<KpiCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
change={metric.change}
|
||||
trend={metric.trend}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<Card className='xl:col-span-5'>
|
||||
<CardHeader>
|
||||
<CardTitle>Enquiry Pipeline</CardTitle>
|
||||
<CardDescription>สถานะ opportunity ปัจจุบัน</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={data.enquiryPipeline}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='value' radius={8} fill='var(--chart-1)' />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-4'>
|
||||
<CardHeader>
|
||||
<CardTitle>Quotation Pipeline</CardTitle>
|
||||
<CardDescription>ภาพรวมเอกสารเสนอราคา</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<PieChart>
|
||||
<Pie data={data.quotationPipeline} dataKey='value' nameKey='label' innerRadius={56} outerRadius={92}>
|
||||
{data.quotationPipeline.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.label}
|
||||
fill={['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'][index % 5]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-3'>
|
||||
<CardHeader>
|
||||
<CardTitle>Competitor Analysis</CardTitle>
|
||||
<CardDescription>คู่แข่งที่เจอบ่อยใน pipeline</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{data.competitorAnalysis.map((item) => (
|
||||
<div key={item.label} className='space-y-1'>
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<span>{item.label}</span>
|
||||
<span className='font-medium'>{item.value}</span>
|
||||
</div>
|
||||
<div className='bg-muted h-2 rounded-full'>
|
||||
<div
|
||||
className='bg-primary h-2 rounded-full'
|
||||
style={{ width: `${(item.value / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<Card className='xl:col-span-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Performance</CardTitle>
|
||||
<CardDescription>จำนวน quotation และมูลค่าแยกตาม salesperson</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={data.salesPerformance}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='value' fill='var(--chart-2)' radius={8} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='xl:col-span-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Won / Lost Trend</CardTitle>
|
||||
<CardDescription>เทียบจำนวนดีลชนะและแพ้รายเดือน</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart data={data.wonLostTrend}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area type='monotone' dataKey='value' stroke='var(--chart-1)' fill='var(--chart-1)' fillOpacity={0.2} />
|
||||
<Area type='monotone' dataKey='secondaryValue' stroke='var(--chart-3)' fill='var(--chart-3)' fillOpacity={0.12} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Pending Approvals' description='รายการที่ต้องตัดสินใจวันนี้' className='xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
{data.pendingApprovals.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='font-medium'>{item.quotationCode}</p>
|
||||
<QuotationStatusBadge status='pending_approval' />
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>{item.productType} / Approver Level {item.approverLevel}</p>
|
||||
<p className='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Hot Projects' description='ดีลที่ต้องติดตามใกล้ชิด' className='xl:col-span-5'>
|
||||
<RelatedQuotationList quotations={data.hotQuotations} />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Follow-ups Due Today' description='กิจกรรมติดตามที่ครบกำหนด' className='xl:col-span-3'>
|
||||
<div className='space-y-3'>
|
||||
{data.dueFollowUps.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-3'>
|
||||
<p className='font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>Owner: {item.ownerName}</p>
|
||||
<p className='text-muted-foreground text-sm'>Due: {formatDate(item.dueDate)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { customerByIdOptions } from '../api/queries';
|
||||
import { revokeContactShareMutation, shareContactMutation } from '../api/mutations';
|
||||
import {
|
||||
AuditTimeline,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
RelatedQuotationList,
|
||||
SectionCard,
|
||||
TimelineList
|
||||
} from './shared';
|
||||
import { formatDate } from '../utils/format';
|
||||
|
||||
export function CustomerDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(id));
|
||||
const [targetUserName, setTargetUserName] = useState('');
|
||||
const baseShareMutation = shareContactMutation;
|
||||
const baseRevokeMutation = revokeContactShareMutation;
|
||||
|
||||
const shareMutation = useMutation({
|
||||
...baseShareMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseShareMutation.onSuccess?.(...args);
|
||||
toast.success('แชร์ contact mock สำเร็จ');
|
||||
setTargetUserName('');
|
||||
}
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
...baseRevokeMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRevokeMutation.onSuccess?.(...args);
|
||||
toast.success('ยกเลิกแชร์ contact mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const primaryContact = data.contacts.find((contact) => contact.isPrimary) ?? data.contacts[0];
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Customer Code', value: data.customer.code },
|
||||
{ label: 'Primary Contact', value: primaryContact?.name ?? '-' },
|
||||
{ label: 'Branch', value: data.branch.name },
|
||||
{
|
||||
label: 'Last Activity',
|
||||
value: data.auditEvents[0] ? formatDate(data.auditEvents[0].createdAt) : '-'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue='overview' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
||||
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
|
||||
<TabsTrigger value='enquiries'>Enquiries</TabsTrigger>
|
||||
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview'>
|
||||
<SectionCard title='Customer Overview'>
|
||||
<InfoGrid
|
||||
cols={3}
|
||||
items={[
|
||||
{ label: 'Tax ID', value: data.customer.taxId },
|
||||
{ label: 'Email', value: data.customer.email },
|
||||
{ label: 'Phone', value: data.customer.phone },
|
||||
{
|
||||
label: 'Address',
|
||||
value: `${data.customer.address}, ${data.customer.subDistrict}`
|
||||
},
|
||||
{ label: 'District', value: data.customer.district },
|
||||
{ label: 'Province', value: data.customer.province }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='contacts'>
|
||||
<SectionCard title='Customer Contacts'>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
{data.contacts.map((contact) => (
|
||||
<div key={contact.id} className='rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='font-medium'>{contact.name}</p>
|
||||
{contact.isPrimary ? (
|
||||
<span className='text-primary text-xs'>Primary</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{contact.position}</p>
|
||||
<p className='mt-2 text-sm'>{contact.email}</p>
|
||||
<p className='text-sm'>{contact.phone}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='shared'>
|
||||
<SectionCard title='Share Contact' description='Mock contact sharing UI'>
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Input
|
||||
placeholder='ชื่อผู้ใช้ที่ต้องการแชร์'
|
||||
value={targetUserName}
|
||||
onChange={(event) => setTargetUserName(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isLoading={shareMutation.isPending}
|
||||
onClick={() =>
|
||||
primaryContact &&
|
||||
targetUserName &&
|
||||
shareMutation.mutate({
|
||||
contactId: primaryContact.id,
|
||||
sharedWithUserId: `mock-${targetUserName}`,
|
||||
sharedWithUserName: targetUserName,
|
||||
permission: 'view'
|
||||
})
|
||||
}
|
||||
>
|
||||
แชร์ Contact
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-4 space-y-3'>
|
||||
{data.shares.length === 0 ? (
|
||||
<EmptyState
|
||||
title='ยังไม่มีการแชร์ contact'
|
||||
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
|
||||
/>
|
||||
) : (
|
||||
data.shares.map((share) => (
|
||||
<div
|
||||
key={share.id}
|
||||
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{share.sharedWithUserName}</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Permission: {share.permission}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
isLoading={revokeMutation.isPending}
|
||||
onClick={() =>
|
||||
revokeMutation.mutate({
|
||||
contactId: share.contactId,
|
||||
shareId: share.id
|
||||
})
|
||||
}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<TimelineList
|
||||
items={data.shareLogs.map((log) => ({
|
||||
id: log.id,
|
||||
title: log.action,
|
||||
detail: `${log.targetUserName} โดย ${log.actorName}`,
|
||||
timestamp: log.createdAt,
|
||||
tone: log.action === 'SHARE' ? 'success' : 'danger'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='enquiries'>
|
||||
<SectionCard title='Customer Enquiries'>
|
||||
<div className='space-y-3'>
|
||||
{data.enquiries.map((enquiry) => (
|
||||
<div key={enquiry.id} className='rounded-lg border p-3'>
|
||||
<p className='font-medium'>{enquiry.code}</p>
|
||||
<p className='text-muted-foreground text-sm'>{enquiry.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='quotations'>
|
||||
<RelatedQuotationList quotations={data.quotations} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<SectionCard title='Activity Log'>
|
||||
<AuditTimeline events={data.auditEvents} />
|
||||
</SectionCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { crmReferenceQueryOptions } from '../api/queries';
|
||||
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations';
|
||||
import type { Customer } from '../api/types';
|
||||
import { customerStatusOptions } from '../data/options';
|
||||
import { customerSchema, type CustomerFormValues } from '../schemas/customer';
|
||||
|
||||
const customerTypeOptions = [
|
||||
{ value: 'developer', label: 'Developer' },
|
||||
{ value: 'contractor', label: 'Contractor' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'consultant', label: 'Consultant' }
|
||||
];
|
||||
|
||||
export function CustomerFormSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
customer
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
customer?: Customer;
|
||||
}) {
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
const { FormTextField, FormSelectField } = useFormFields<CustomerFormValues>();
|
||||
const isEdit = !!customer;
|
||||
const baseCreateMutation = createCustomerMutation;
|
||||
const baseUpdateMutation = updateCustomerMutation;
|
||||
|
||||
const createMutation = useMutation({
|
||||
...baseCreateMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseCreateMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง customer mock สำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...baseUpdateMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseUpdateMutation.onSuccess?.(...args);
|
||||
toast.success('อัปเดต customer mock สำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'บันทึกไม่สำเร็จ')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
code: customer?.code ?? '',
|
||||
name: customer?.name ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
email: customer?.email ?? '',
|
||||
phone: customer?.phone ?? '',
|
||||
customerType: customer?.customerType ?? 'developer',
|
||||
customerStatus: customer?.customerStatus ?? 'active',
|
||||
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? ''
|
||||
} as CustomerFormValues,
|
||||
validators: {
|
||||
onSubmit: customerSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
if (isEdit && customer) {
|
||||
await updateMutation.mutateAsync({ id: customer.id, values: value });
|
||||
return;
|
||||
}
|
||||
await createMutation.mutateAsync(value);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
form.reset({
|
||||
code: customer?.code ?? '',
|
||||
name: customer?.name ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
email: customer?.email ?? '',
|
||||
phone: customer?.phone ?? '',
|
||||
customerType: customer?.customerType ?? 'developer',
|
||||
customerStatus: customer?.customerStatus ?? 'active',
|
||||
branchId: customer?.branchId ?? reference.branches[0]?.id ?? '',
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? ''
|
||||
});
|
||||
}, [customer, form, open, reference.branches]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-2xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'แก้ไข Customer' : 'สร้าง Customer'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
ใช้ mock form ตาม pattern เดิมของ repo และพร้อมสลับไป service จริงภายหลัง
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='max-h-[calc(100vh-10rem)] overflow-auto pr-2'>
|
||||
<form.AppForm>
|
||||
<form.Form id='customer-form' className='grid gap-2 md:grid-cols-2'>
|
||||
<FormTextField name='code' label='Customer Code' required placeholder='CUS-BKK-006' />
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Customer Name'
|
||||
required
|
||||
placeholder='ALLA Distribution'
|
||||
/>
|
||||
<FormTextField name='taxId' label='Tax ID' required placeholder='0105556...' />
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
required
|
||||
type='email'
|
||||
placeholder='contact@example.com'
|
||||
/>
|
||||
<FormTextField name='phone' label='Phone' required placeholder='02-000-0000' />
|
||||
<FormSelectField
|
||||
name='customerType'
|
||||
label='Customer Type'
|
||||
required
|
||||
options={customerTypeOptions}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerStatus'
|
||||
label='Status'
|
||||
required
|
||||
options={customerStatusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='Branch'
|
||||
required
|
||||
options={reference.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField
|
||||
name='address'
|
||||
label='Address'
|
||||
required
|
||||
placeholder='123 Main Road'
|
||||
/>
|
||||
</div>
|
||||
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
|
||||
<FormTextField
|
||||
name='district'
|
||||
label='District'
|
||||
required
|
||||
placeholder='Chatuchak'
|
||||
/>
|
||||
<FormTextField
|
||||
name='subDistrict'
|
||||
label='Sub District'
|
||||
required
|
||||
placeholder='Chom Phon'
|
||||
/>
|
||||
<FormTextField
|
||||
name='postalCode'
|
||||
label='Postal Code'
|
||||
required
|
||||
placeholder='10900'
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button type='submit' form='customer-form' isLoading={isPending}>
|
||||
{isEdit ? 'บันทึกการแก้ไข' : 'สร้าง Customer'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Icons } from "@/components/icons";
|
||||
import {
|
||||
customersQueryOptions,
|
||||
crmReferenceQueryOptions,
|
||||
} from "../api/queries";
|
||||
import type { Customer } from "../api/types";
|
||||
import { CustomerFormSheet } from "./customer-form-sheet";
|
||||
import { BranchBadge, CustomerStatusBadge } from "./shared";
|
||||
|
||||
export function CustomersTablePage() {
|
||||
const [editingCustomer, setEditingCustomer] = useState<
|
||||
Customer | undefined
|
||||
>();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const columns = useMemo<ColumnDef<Customer>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "code",
|
||||
header: "Code",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Customer",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{row.original.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "customerStatus",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<CustomerStatusBadge status={row.original.customerStatus} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "branchId",
|
||||
header: "Branch",
|
||||
cell: ({ row, table }) => {
|
||||
const branches = (
|
||||
table.options.meta as {
|
||||
branches: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
region: string;
|
||||
}>;
|
||||
}
|
||||
).branches;
|
||||
const branch = branches.find(
|
||||
(item) => item.id === row.original.branchId,
|
||||
);
|
||||
return branch ? <BranchBadge branch={branch} /> : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "contactIds",
|
||||
header: "Contacts",
|
||||
cell: ({ row }) => row.original.contactIds.length,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingCustomer(row.original);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/customers/${row.original.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
branch: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.customerStatus && { status: params.customerStatus }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(customersQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
meta: { branches: reference.branches },
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-3 lg:w-full">
|
||||
<Input
|
||||
placeholder="ค้นหา customer หรือ code"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.customerStatus ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({
|
||||
customerStatus: event.target.value || null,
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="prospect">Prospect</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.branch ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ branch: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสาขา</option>
|
||||
{reference.branches.map((branch) => (
|
||||
<option key={branch.id} value={branch.id}>
|
||||
{branch.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingCustomer(undefined);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.add className="mr-2 h-4 w-4" />
|
||||
Create Customer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable table={table} />
|
||||
<CustomerFormSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpen(open);
|
||||
if (!open) setEditingCustomer(undefined);
|
||||
}}
|
||||
customer={editingCustomer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
crmReferenceQueryOptions,
|
||||
enquiriesQueryOptions,
|
||||
} from "../api/queries";
|
||||
import { convertEnquiryMutation } from "../api/mutations";
|
||||
import type { Enquiry } from "../api/types";
|
||||
import { ChancePill, EnquiryStatusBadge } from "./shared";
|
||||
|
||||
const columns: ColumnDef<Enquiry>[] = [
|
||||
{ accessorKey: "code", header: "Code" },
|
||||
{ accessorKey: "title", header: "Enquiry" },
|
||||
{ accessorKey: "productType", header: "Product" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "chancePercent",
|
||||
header: "Chance",
|
||||
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => <RowActions enquiry={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
function RowActions({ enquiry }: { enquiry: Enquiry }) {
|
||||
const mutation = useMutation({
|
||||
...convertEnquiryMutation,
|
||||
onSuccess: () => toast.success("สร้าง quotation mock จาก enquiry แล้ว"),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/enquiries/${enquiry.id}`}>View</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={mutation.isPending}
|
||||
onClick={() => mutation.mutate(enquiry.id)}
|
||||
>
|
||||
Convert
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiriesTablePage() {
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
productType: parseAsString,
|
||||
salesman: parseAsString,
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
...(params.salesman && { salesman: params.salesman }),
|
||||
...(params.dateFrom && { dateFrom: params.dateFrom }),
|
||||
...(params.dateTo && { dateTo: params.dateTo }),
|
||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}),
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<Input
|
||||
placeholder="ค้นหา enquiry หรือ code"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.status ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ status: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="new">New</option>
|
||||
<option value="qualifying">Qualifying</option>
|
||||
<option value="requirement">Requirement</option>
|
||||
<option value="follow_up">Follow Up</option>
|
||||
<option value="converted">Converted</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.productType ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ productType: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสินค้า</option>
|
||||
<option value="crane">Crane</option>
|
||||
<option value="dockdoor">Dock Door</option>
|
||||
<option value="solarcell">Solar Cell</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.salesman ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุก salesperson</option>
|
||||
{reference.salespersons.map((sales) => (
|
||||
<option key={sales.id} value={sales.id}>
|
||||
{sales.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateFrom ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateTo ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/crm/customers">Create Enquiry</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { enquiryByIdOptions } from '../api/queries';
|
||||
import { convertEnquiryMutation } from '../api/mutations';
|
||||
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
export function EnquiryDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(id));
|
||||
const baseMutation = convertEnquiryMutation;
|
||||
const mutation = useMutation({
|
||||
...baseMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง quotation mock เรียบร้อย');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<SectionCard
|
||||
title={data.enquiry.title}
|
||||
description={`${data.enquiry.code} / ${data.branch.name}`}
|
||||
action={
|
||||
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.enquiry.id)}>
|
||||
Convert to Quotation
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Customer', value: data.customer.name },
|
||||
{ label: 'Contact', value: data.contact.name },
|
||||
{ label: 'Chance', value: `${data.enquiry.chancePercent}%` },
|
||||
{ label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) },
|
||||
{ label: 'Project Location', value: data.enquiry.projectLocation },
|
||||
{ label: 'Competitor', value: data.enquiry.competitor },
|
||||
{ label: 'Salesman', value: data.salesman.name },
|
||||
{ label: 'Requirement', value: data.enquiry.requirementSummary }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Timeline / Activity'>
|
||||
<TimelineList
|
||||
items={data.enquiry.activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
detail: activity.detail,
|
||||
actorName: activity.actorName,
|
||||
timestamp: activity.createdAt
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Related Quotations'>
|
||||
<RelatedQuotationList quotations={data.quotations} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { quotationByIdOptions } from '../api/queries';
|
||||
import {
|
||||
approveQuotationMutation,
|
||||
createQuotationRevisionMutation,
|
||||
markQuotationStatusMutation,
|
||||
rejectQuotationMutation,
|
||||
submitApprovalMutation
|
||||
} from '../api/mutations';
|
||||
import {
|
||||
AuditTimeline,
|
||||
CustomerRoleCards,
|
||||
InfoGrid,
|
||||
QuotationPreviewPanel,
|
||||
QuotationStatusBadge,
|
||||
SectionCard,
|
||||
TimelineList
|
||||
} from './shared';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
|
||||
function ApprovalDialog({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onConfirm,
|
||||
isPending
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string;
|
||||
onConfirm: (comment: string) => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant='outline'>{actionLabel}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder='ใส่หมายเหตุ (optional)'
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||
ยืนยัน
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationDetailPage({ id }: { id: string }) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(id));
|
||||
const baseSubmitApproval = submitApprovalMutation;
|
||||
const baseApproveMutation = approveQuotationMutation;
|
||||
const baseRejectMutation = rejectQuotationMutation;
|
||||
const baseReviseMutation = createQuotationRevisionMutation;
|
||||
const baseMarkStatusMutation = markQuotationStatusMutation;
|
||||
|
||||
const submitApproval = useMutation({
|
||||
...baseSubmitApproval,
|
||||
onSuccess: async (...args) => {
|
||||
await baseSubmitApproval.onSuccess?.(...args);
|
||||
toast.success('ส่งเข้าสายอนุมัติแล้ว');
|
||||
}
|
||||
});
|
||||
const approveMutation = useMutation({
|
||||
...baseApproveMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseApproveMutation.onSuccess?.(...args);
|
||||
toast.success('อนุมัติ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const rejectMutation = useMutation({
|
||||
...baseRejectMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseRejectMutation.onSuccess?.(...args);
|
||||
toast.success('Reject mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const reviseMutation = useMutation({
|
||||
...baseReviseMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseReviseMutation.onSuccess?.(...args);
|
||||
toast.success('สร้าง revision mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
const markStatusMutation = useMutation({
|
||||
...baseMarkStatusMutation,
|
||||
onSuccess: async (...args) => {
|
||||
await baseMarkStatusMutation.onSuccess?.(...args);
|
||||
toast.success('อัปเดตสถานะ mock สำเร็จ');
|
||||
}
|
||||
});
|
||||
|
||||
const ownerCustomer = data.customers.find((customer) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.customerId === customer.id
|
||||
)
|
||||
);
|
||||
const ownerContact = data.contacts.find((contact) =>
|
||||
data.quotation.customerRoles.some(
|
||||
(role) => role.role === 'owner' && role.contactId === contact.id
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<SectionCard
|
||||
title={data.quotation.project}
|
||||
description={`${data.quotation.code} / ${data.branch.name}`}
|
||||
action={
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={submitApproval.isPending}
|
||||
onClick={() => submitApproval.mutate(data.quotation.id)}
|
||||
>
|
||||
Submit approval
|
||||
</Button>
|
||||
<ApprovalDialog
|
||||
title='Approve quotation'
|
||||
description='Mock sequential approval for demo.'
|
||||
actionLabel='Approve'
|
||||
isPending={approveMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
approveMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<ApprovalDialog
|
||||
title='Reject quotation'
|
||||
description='Mock reject flow for demo.'
|
||||
actionLabel='Reject'
|
||||
isPending={rejectMutation.isPending}
|
||||
onConfirm={(comment) =>
|
||||
rejectMutation.mutate({ quotationId: data.quotation.id, comment })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={reviseMutation.isPending}
|
||||
onClick={() => reviseMutation.mutate(data.quotation.id)}
|
||||
>
|
||||
Create revision
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'sent'
|
||||
})
|
||||
}
|
||||
>
|
||||
Send to customer
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'accepted'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark accepted
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
isLoading={markStatusMutation.isPending}
|
||||
onClick={() =>
|
||||
markStatusMutation.mutate({
|
||||
quotationId: data.quotation.id,
|
||||
status: 'lost'
|
||||
})
|
||||
}
|
||||
>
|
||||
Mark lost
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<QuotationStatusBadge status={data.quotation.status} />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
Revision R{String(data.quotation.revision).padStart(2, '0')}
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{formatCurrency(data.quotation.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='mt-4'>
|
||||
<InfoGrid
|
||||
cols={4}
|
||||
items={[
|
||||
{ label: 'Quotation Date', value: data.quotation.quotationDate },
|
||||
{ label: 'Valid Until', value: data.quotation.validUntil },
|
||||
{ label: 'Type', value: data.quotation.quotationType },
|
||||
{ label: 'Chance', value: `${data.quotation.chancePercent}%` },
|
||||
{ label: 'Salesman', value: data.salesman.name },
|
||||
{ label: 'Sales Admin', value: data.saleAdmin.name },
|
||||
{ label: 'Branch', value: data.branch.name },
|
||||
{ label: 'Site', value: data.quotation.siteLocation }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Customer Roles'>
|
||||
<CustomerRoleCards
|
||||
roles={data.quotation.customerRoles}
|
||||
customers={data.customers}
|
||||
contacts={data.contacts}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Item Table' className='xl:col-span-7'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='grid gap-2 rounded-lg border p-3 md:grid-cols-[1fr_auto_auto]'
|
||||
>
|
||||
<div>
|
||||
<p className='font-medium'>{item.description}</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.topic}</p>
|
||||
</div>
|
||||
<p className='text-sm'>
|
||||
{item.quantity} {item.unit}
|
||||
</p>
|
||||
<p className='text-sm font-semibold'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard title='Total Summary' className='xl:col-span-5'>
|
||||
<InfoGrid
|
||||
cols={2}
|
||||
items={[
|
||||
{ label: 'Sub Total', value: formatCurrency(data.quotation.totalAmount) },
|
||||
{ label: 'VAT 7%', value: formatCurrency(data.quotation.totalAmount * 0.07) },
|
||||
{ label: 'Grand Total', value: formatCurrency(data.quotation.totalAmount * 1.07) },
|
||||
{ label: 'Hot Project', value: data.quotation.isHotProject ? 'Yes' : 'No' }
|
||||
]}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Topics' className='xl:col-span-5'>
|
||||
<div className='space-y-4'>
|
||||
{data.quotation.topics.map((topic) => (
|
||||
<div key={topic.id}>
|
||||
<p className='font-medium capitalize'>{topic.label}</p>
|
||||
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
|
||||
{topic.items.map((item) => (
|
||||
<li key={item}>- {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Attachments' className='xl:col-span-3'>
|
||||
<div className='space-y-3'>
|
||||
{data.quotation.attachments.map((attachment) => (
|
||||
<div key={attachment.id} className='rounded-lg border p-3 text-sm'>
|
||||
<p className='font-medium'>{attachment.fileName}</p>
|
||||
<p className='text-muted-foreground'>{attachment.fileType.toUpperCase()}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Follow-ups' className='xl:col-span-4'>
|
||||
<TimelineList
|
||||
items={data.quotation.followUps.map((followUp) => ({
|
||||
id: followUp.id,
|
||||
title: followUp.title,
|
||||
detail: `Owner: ${followUp.ownerName}`,
|
||||
timestamp: followUp.dueDate,
|
||||
tone: followUp.status === 'done' ? 'success' : 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-12'>
|
||||
<SectionCard title='Approval Timeline' className='xl:col-span-5'>
|
||||
<TimelineList
|
||||
items={data.quotation.approvalSteps.map((step) => ({
|
||||
id: step.id,
|
||||
title: `Level ${step.level} - ${step.approverName}`,
|
||||
detail: `${step.approverPosition}${step.comment ? ` / ${step.comment}` : ''}`,
|
||||
timestamp: step.actedAt ?? data.quotation.quotationDate,
|
||||
tone:
|
||||
step.status === 'approved'
|
||||
? 'success'
|
||||
: step.status === 'rejected'
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}))}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title='Audit Timeline' className='xl:col-span-7'>
|
||||
<AuditTimeline events={data.auditEvents} />
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<QuotationPreviewPanel
|
||||
quotation={data.quotation}
|
||||
ownerCustomer={ownerCustomer}
|
||||
ownerContact={ownerContact}
|
||||
approvalSteps={data.quotation.approvalSteps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { getSortingStateParser } from "@/lib/parsers";
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { DataTable } from "@/components/ui/table/data-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { HotProjectPill, QuotationStatusBadge } from "./shared";
|
||||
import {
|
||||
crmReferenceQueryOptions,
|
||||
quotationsQueryOptions,
|
||||
} from "../api/queries";
|
||||
import type { Quotation } from "../api/types";
|
||||
import { formatCurrency } from "../utils/format";
|
||||
|
||||
const columns: ColumnDef<Quotation>[] = [
|
||||
{ accessorKey: "code", header: "Code" },
|
||||
{ accessorKey: "project", header: "Project" },
|
||||
{ accessorKey: "quotationType", header: "Type" },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{ accessorKey: "revision", header: "Rev" },
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: "Amount",
|
||||
cell: ({ row }) => formatCurrency(row.original.totalAmount),
|
||||
},
|
||||
{
|
||||
accessorKey: "isHotProject",
|
||||
header: "Hot",
|
||||
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/dashboard/crm/quotations/${row.original.id}`}>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const columnIds = columns
|
||||
.map((column) => column.id ?? String(column.accessorKey))
|
||||
.filter(Boolean);
|
||||
|
||||
function KanbanView({ items }: { items: Quotation[] }) {
|
||||
const statuses = [
|
||||
"draft",
|
||||
"pending_approval",
|
||||
"approved",
|
||||
"sent",
|
||||
"accepted",
|
||||
"lost",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-6">
|
||||
{statuses.map((status) => (
|
||||
<Card key={status} className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold capitalize">
|
||||
{status.replace("_", " ")}
|
||||
</p>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{items.filter((item) => item.status === status).length}
|
||||
</span>
|
||||
</div>
|
||||
{items
|
||||
.filter((item) => item.status === status)
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/dashboard/crm/quotations/${item.id}`}
|
||||
className="block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<p className="font-medium">{item.code}</p>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2">
|
||||
{item.project}
|
||||
</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{formatCurrency(item.totalAmount)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationsTablePage() {
|
||||
const [params, setParams] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
quotationType: parseAsString,
|
||||
salesman: parseAsString,
|
||||
hot: parseAsString,
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
view: parseAsString.withDefault("table"),
|
||||
sort: getSortingStateParser(columnIds).withDefault([]),
|
||||
});
|
||||
const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.quotationType && { quotationType: params.quotationType }),
|
||||
...(params.salesman && { salesman: params.salesman }),
|
||||
...(params.hot && { hot: params.hot }),
|
||||
...(params.dateFrom && { dateFrom: params.dateFrom }),
|
||||
...(params.dateTo && { dateTo: params.dateTo }),
|
||||
...(params.sort.length ? { 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,
|
||||
initialState: {
|
||||
columnPinning: { right: ["actions"] },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col space-y-4">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full">
|
||||
<Input
|
||||
placeholder="ค้นหา code หรือ project"
|
||||
value={params.name ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ name: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.status ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ status: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกสถานะ</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_approval">Pending Approval</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="sent">Sent</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.quotationType ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({
|
||||
quotationType: event.target.value || null,
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">ทุกประเภท</option>
|
||||
<option value="official">Official</option>
|
||||
<option value="budgetary">Budgetary</option>
|
||||
<option value="service">Service</option>
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.salesman ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุก salesperson</option>
|
||||
{reference.salespersons.map((sales) => (
|
||||
<option key={sales.id} value={sales.id}>
|
||||
{sales.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
||||
value={params.hot ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ hot: event.target.value || null, page: 1 })
|
||||
}
|
||||
>
|
||||
<option value="">ทุกดีล</option>
|
||||
<option value="yes">Hot only</option>
|
||||
<option value="no">Non-hot</option>
|
||||
</select>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateFrom ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={params.dateTo ?? ""}
|
||||
onChange={(event) =>
|
||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dashboard/crm/enquiries">Create quotation</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant={params.view === "table" ? "default" : "outline"}
|
||||
onClick={() => void setParams({ view: "table" })}
|
||||
>
|
||||
Table
|
||||
</Button>
|
||||
<Button
|
||||
variant={params.view === "kanban" ? "default" : "outline"}
|
||||
onClick={() => void setParams({ view: "kanban" })}
|
||||
>
|
||||
Kanban
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{params.view === "kanban" ? (
|
||||
<KanbanView items={data.items} />
|
||||
) : (
|
||||
<DataTable table={table} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { crmReferenceQueryOptions } from '../api/queries';
|
||||
import { EmptyState, SectionCard } from './shared';
|
||||
|
||||
export function MasterOptionsPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
return (
|
||||
<SectionCard title='Master Options' description='Status, product type, payment term, currency, tax rate'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{data.masterOptions.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4'>
|
||||
<p className='text-muted-foreground text-xs uppercase tracking-[0.2em]'>{item.group}</p>
|
||||
<p className='mt-2 font-medium'>{item.label}</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.code}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentSequencesPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
return (
|
||||
<SectionCard title='Document Sequences' description='Mock document sequence by branch'>
|
||||
<div className='space-y-3'>
|
||||
{data.sequences.map((item) => (
|
||||
<div key={item.id} className='rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='font-medium'>
|
||||
{item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>Branch: {item.branchId}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplatesPage() {
|
||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||
|
||||
if (data.templates.length === 0) {
|
||||
return <EmptyState title='No templates' description='ยังไม่มี quotation templates' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title='Quotation Templates' description='Mock template and placeholder mappings'>
|
||||
<div className='space-y-4'>
|
||||
{data.templates.map((template) => (
|
||||
<div key={template.id} className='rounded-xl border p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-semibold'>{template.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>{template.description}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{template.version}</p>
|
||||
</div>
|
||||
<div className='mt-3 grid gap-3 md:grid-cols-2'>
|
||||
{template.mappings.map((mapping) => (
|
||||
<div key={mapping.id} className='rounded-lg bg-muted/20 p-3 text-sm'>
|
||||
<p className='font-medium'>{mapping.placeholder}</p>
|
||||
<p className='text-muted-foreground'>{mapping.sourceField}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
ApprovalStep,
|
||||
AuditEvent,
|
||||
CrmBranch,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
Enquiry,
|
||||
Quotation,
|
||||
QuotationCustomerRole
|
||||
} from '../api/types';
|
||||
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
|
||||
import {
|
||||
getCustomerStatusLabel,
|
||||
getEnquiryStatusLabel,
|
||||
getQuotationStatusLabel
|
||||
} from '../utils/status';
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className='flex flex-row items-start justify-between space-y-0'>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle className='text-base'>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</div>
|
||||
{action}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
trend
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
change: string;
|
||||
trend: 'up' | 'down' | 'flat';
|
||||
}) {
|
||||
const TrendIcon =
|
||||
trend === 'up' ? Icons.trendingUp : trend === 'down' ? Icons.trendingDown : Icons.clock;
|
||||
|
||||
return (
|
||||
<Card className='bg-gradient-to-br from-card via-card to-muted/40'>
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<Badge variant='outline'>
|
||||
<TrendIcon className='mr-1 h-3.5 w-3.5' />
|
||||
{change}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className='text-2xl'>{value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchBadge({ branch }: { branch: CrmBranch }) {
|
||||
return <Badge variant='secondary'>{branch.code}</Badge>;
|
||||
}
|
||||
|
||||
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
|
||||
return (
|
||||
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
|
||||
{getCustomerStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
status === 'cancelled' || status === 'closed_lost'
|
||||
? 'destructive'
|
||||
: status === 'converted'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{getEnquiryStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationStatusBadge({ status }: { status: Quotation['status'] }) {
|
||||
return (
|
||||
<Badge
|
||||
variant={
|
||||
status === 'accepted' || status === 'approved'
|
||||
? 'secondary'
|
||||
: status === 'lost' || status === 'rejected' || status === 'cancelled'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{getQuotationStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoGrid({
|
||||
items,
|
||||
cols = 2
|
||||
}: {
|
||||
items: Array<{ label: string; value: React.ReactNode }>;
|
||||
cols?: 2 | 3 | 4;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4', cols === 2 && 'md:grid-cols-2', cols === 3 && 'md:grid-cols-3', cols === 4 && 'md:grid-cols-2 xl:grid-cols-4')}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className='rounded-lg border bg-muted/20 p-3'>
|
||||
<p className='text-muted-foreground text-xs uppercase tracking-[0.18em]'>{item.label}</p>
|
||||
<div className='mt-1 text-sm font-medium'>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineList({
|
||||
items
|
||||
}: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
actorName?: string;
|
||||
timestamp: string;
|
||||
tone?: 'default' | 'success' | 'danger';
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className='flex gap-3'>
|
||||
<div className='mt-1 flex flex-col items-center'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-2.5 w-2.5 rounded-full bg-primary',
|
||||
item.tone === 'success' && 'bg-emerald-500',
|
||||
item.tone === 'danger' && 'bg-destructive'
|
||||
)}
|
||||
/>
|
||||
<span className='bg-border mt-1 h-full w-px' />
|
||||
</div>
|
||||
<div className='pb-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground text-xs'>{formatDate(item.timestamp)}</p>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{item.detail}</p>
|
||||
{item.actorName ? <p className='mt-1 text-xs'>โดย {item.actorName}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerRoleCards({
|
||||
roles,
|
||||
customers,
|
||||
contacts
|
||||
}: {
|
||||
roles: QuotationCustomerRole[];
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{roles.map((role) => {
|
||||
const customer = customers.find((item) => item.id === role.customerId);
|
||||
const contact = contacts.find((item) => item.id === role.contactId);
|
||||
|
||||
return (
|
||||
<div key={`${role.role}-${role.customerId}`} className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<p className='text-sm font-semibold capitalize'>{role.role}</p>
|
||||
<Badge variant='outline'>{customer?.code}</Badge>
|
||||
</div>
|
||||
<p className='mt-2 text-sm font-medium'>{customer?.name}</p>
|
||||
<p className='text-muted-foreground text-sm'>{contact?.name ?? 'No contact'}</p>
|
||||
<p className='text-muted-foreground text-xs'>{contact?.position}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuotationPreviewPanel({
|
||||
quotation,
|
||||
ownerCustomer,
|
||||
ownerContact,
|
||||
approvalSteps
|
||||
}: {
|
||||
quotation: Quotation;
|
||||
ownerCustomer?: Customer;
|
||||
ownerContact?: CustomerContact;
|
||||
approvalSteps: ApprovalStep[];
|
||||
}) {
|
||||
return (
|
||||
<SectionCard title='Quotation Preview' description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'>
|
||||
<div className='rounded-xl border bg-background p-5 shadow-sm'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
|
||||
<div>
|
||||
<p className='text-primary text-xs uppercase tracking-[0.3em]'>ALLA OS CRM vNext</p>
|
||||
<h3 className='mt-2 text-xl font-semibold'>{quotation.code}</h3>
|
||||
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
||||
</div>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p><span className='text-muted-foreground'>quotation_date:</span> {quotation.quotationDate}</p>
|
||||
<p><span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}</p>
|
||||
<p><span className='text-muted-foreground'>currency:</span> THB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>customer_name</p>
|
||||
<p>{ownerCustomer?.name}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.address}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.phone}</p>
|
||||
<p className='text-muted-foreground'>{ownerCustomer?.email}</p>
|
||||
</div>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<p className='font-medium'>customer_att</p>
|
||||
<p>{ownerContact?.name ?? '-'}</p>
|
||||
<p className='text-muted-foreground'>{ownerContact?.position ?? '-'}</p>
|
||||
<p className='text-muted-foreground'>{quotation.siteLocation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 border-t pt-4'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>item_topic</p>
|
||||
<div className='mt-2 space-y-2'>
|
||||
{quotation.items.map((item) => (
|
||||
<div key={item.id} className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'>
|
||||
<div>
|
||||
<p className='font-medium'>{item.description}</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{item.quantity} {item.unit} x {formatCurrency(item.unitPrice)}
|
||||
</p>
|
||||
</div>
|
||||
<p className='font-semibold'>{formatCurrency(item.amount)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
|
||||
<p className='font-medium'>exclusion_data</p>
|
||||
<ul className='text-muted-foreground mt-1 space-y-1'>
|
||||
{quotation.topics
|
||||
.find((topic) => topic.type === 'exclusion')
|
||||
?.items.map((item) => <li key={item}>- {item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
|
||||
<p className='font-medium'>quotation_price</p>
|
||||
<p className='text-lg font-semibold'>{formatCurrency(quotation.totalAmount)}</p>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/30 p-3 text-sm'>
|
||||
<p className='font-medium'>approver names and positions</p>
|
||||
<ul className='text-muted-foreground mt-1 space-y-1'>
|
||||
{approvalSteps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.approverName} / {step.approverPosition}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{quotations.map((quotation) => (
|
||||
<div key={quotation.id} className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='font-medium'>{quotation.code}</p>
|
||||
<QuotationStatusBadge status={quotation.status} />
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
|
||||
<Button variant='outline' asChild size='sm'>
|
||||
<Link href={`/dashboard/crm/quotations/${quotation.id}`}>
|
||||
<Icons.arrowRight className='mr-1 h-4 w-4' />
|
||||
เปิด
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotProjectPill({ active }: { active: boolean }) {
|
||||
return active ? <Badge>Hot</Badge> : <Badge variant='outline'>Normal</Badge>;
|
||||
}
|
||||
|
||||
export function ChancePill({ value }: { value: number }) {
|
||||
return <Badge variant='outline'>{formatPercent(value)}</Badge>;
|
||||
}
|
||||
|
||||
export function AuditTimeline({ events }: { events: AuditEvent[] }) {
|
||||
return (
|
||||
<TimelineList
|
||||
items={events.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.action,
|
||||
detail: event.detail,
|
||||
actorName: event.actorName,
|
||||
timestamp: event.createdAt,
|
||||
tone:
|
||||
event.action === 'APPROVE'
|
||||
? 'success'
|
||||
: event.action === 'REJECT'
|
||||
? 'danger'
|
||||
: 'default'
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-8 text-center'>
|
||||
<p className='font-medium'>{title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,731 +0,0 @@
|
||||
import type {
|
||||
ApprovalItem,
|
||||
AuditEvent,
|
||||
ContactShare,
|
||||
ContactShareLog,
|
||||
CrmBranch,
|
||||
CrmSalesperson,
|
||||
Customer,
|
||||
CustomerContact,
|
||||
DocumentSequence,
|
||||
Enquiry,
|
||||
MasterOption,
|
||||
Quotation,
|
||||
QuotationTemplate
|
||||
} from '../api/types';
|
||||
|
||||
export interface CrmMockState {
|
||||
branches: CrmBranch[];
|
||||
salespersons: CrmSalesperson[];
|
||||
customers: Customer[];
|
||||
contacts: CustomerContact[];
|
||||
contactShares: ContactShare[];
|
||||
contactShareLogs: ContactShareLog[];
|
||||
enquiries: Enquiry[];
|
||||
quotations: Quotation[];
|
||||
approvals: ApprovalItem[];
|
||||
auditEvents: AuditEvent[];
|
||||
sequences: DocumentSequence[];
|
||||
masterOptions: MasterOption[];
|
||||
templates: QuotationTemplate[];
|
||||
}
|
||||
|
||||
export const initialCrmState: CrmMockState = {
|
||||
branches: [
|
||||
{ id: 'bkk', code: 'BKK', name: 'Bangkok HQ', region: 'Central' },
|
||||
{ id: 'chn', code: 'CHN', name: 'Chiang Mai Branch', region: 'North' }
|
||||
],
|
||||
salespersons: [
|
||||
{ id: 'sp-1', name: 'Krit S.', nickname: 'Krit', branchId: 'bkk', role: 'sales' },
|
||||
{ id: 'sp-2', name: 'Nicha P.', nickname: 'Nicha', branchId: 'bkk', role: 'sale_admin' },
|
||||
{ id: 'sp-3', name: 'Ton A.', nickname: 'Ton', branchId: 'chn', role: 'sales' }
|
||||
],
|
||||
customers: [
|
||||
{
|
||||
id: 'cus-1',
|
||||
code: 'CUS-BKK-001',
|
||||
name: 'Siam Metro Development',
|
||||
taxId: '0105556100011',
|
||||
address: '88 Rama 9 Road',
|
||||
province: 'Bangkok',
|
||||
district: 'Huai Khwang',
|
||||
subDistrict: 'Bang Kapi',
|
||||
postalCode: '10310',
|
||||
phone: '02-555-1001',
|
||||
fax: '02-555-1999',
|
||||
email: 'procurement@siammetro.co.th',
|
||||
customerType: 'developer',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-1', 'ct-2']
|
||||
},
|
||||
{
|
||||
id: 'cus-2',
|
||||
code: 'CUS-BKK-002',
|
||||
name: 'Prime Lift Engineering',
|
||||
taxId: '0105556100012',
|
||||
address: '19 Vibhavadi Rangsit',
|
||||
province: 'Bangkok',
|
||||
district: 'Chatuchak',
|
||||
subDistrict: 'Chom Phon',
|
||||
postalCode: '10900',
|
||||
phone: '02-555-2002',
|
||||
email: 'sales@primelift.co.th',
|
||||
customerType: 'contractor',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-3', 'ct-4']
|
||||
},
|
||||
{
|
||||
id: 'cus-3',
|
||||
code: 'CUS-CHN-003',
|
||||
name: 'Northern Cold Chain',
|
||||
taxId: '0505556100013',
|
||||
address: '125 Super Highway',
|
||||
province: 'Chiang Mai',
|
||||
district: 'Mueang',
|
||||
subDistrict: 'Wat Ket',
|
||||
postalCode: '50000',
|
||||
phone: '053-555-3003',
|
||||
email: 'project@ncc.co.th',
|
||||
customerType: 'owner',
|
||||
customerStatus: 'prospect',
|
||||
branchId: 'chn',
|
||||
contactIds: ['ct-5', 'ct-6']
|
||||
},
|
||||
{
|
||||
id: 'cus-4',
|
||||
code: 'CUS-BKK-004',
|
||||
name: 'Urban Dock Solution',
|
||||
taxId: '0105556100014',
|
||||
address: '77 Bangna-Trad KM.8',
|
||||
province: 'Samut Prakan',
|
||||
district: 'Bang Phli',
|
||||
subDistrict: 'Bang Kaeo',
|
||||
postalCode: '10540',
|
||||
phone: '02-555-4004',
|
||||
email: 'admin@urbandock.asia',
|
||||
customerType: 'consultant',
|
||||
customerStatus: 'active',
|
||||
branchId: 'bkk',
|
||||
contactIds: ['ct-7', 'ct-8']
|
||||
},
|
||||
{
|
||||
id: 'cus-5',
|
||||
code: 'CUS-CHN-005',
|
||||
name: 'Lanna Solar Estate',
|
||||
taxId: '0505556100015',
|
||||
address: '199 Ring Road',
|
||||
province: 'Chiang Mai',
|
||||
district: 'San Sai',
|
||||
subDistrict: 'Nong Chom',
|
||||
postalCode: '50210',
|
||||
phone: '053-555-5005',
|
||||
email: 'energy@lannasolar.co.th',
|
||||
customerType: 'developer',
|
||||
customerStatus: 'inactive',
|
||||
branchId: 'chn',
|
||||
contactIds: ['ct-9', 'ct-10']
|
||||
}
|
||||
],
|
||||
contacts: [
|
||||
{ id: 'ct-1', customerId: 'cus-1', name: 'Ploy Tantip', position: 'Procurement Manager', email: 'ploy@siammetro.co.th', phone: '081-111-1111', isPrimary: true },
|
||||
{ id: 'ct-2', customerId: 'cus-1', name: 'Vee Chan', position: 'Project Engineer', email: 'vee@siammetro.co.th', phone: '081-111-1112' },
|
||||
{ id: 'ct-3', customerId: 'cus-2', name: 'Boss K.', position: 'Managing Director', email: 'boss@primelift.co.th', phone: '082-222-2221', isPrimary: true },
|
||||
{ id: 'ct-4', customerId: 'cus-2', name: 'Jane R.', position: 'Estimator', email: 'jane@primelift.co.th', phone: '082-222-2222' },
|
||||
{ id: 'ct-5', customerId: 'cus-3', name: 'Aon M.', position: 'Operations Head', email: 'aon@ncc.co.th', phone: '083-333-3331', isPrimary: true },
|
||||
{ id: 'ct-6', customerId: 'cus-3', name: 'Max P.', position: 'Warehouse Lead', email: 'max@ncc.co.th', phone: '083-333-3332' },
|
||||
{ id: 'ct-7', customerId: 'cus-4', name: 'Mint T.', position: 'Design Consultant', email: 'mint@urbandock.asia', phone: '084-444-4441', isPrimary: true },
|
||||
{ id: 'ct-8', customerId: 'cus-4', name: 'Ken D.', position: 'Project Coordinator', email: 'ken@urbandock.asia', phone: '084-444-4442' },
|
||||
{ id: 'ct-9', customerId: 'cus-5', name: 'Fah N.', position: 'Energy Planning Lead', email: 'fah@lannasolar.co.th', phone: '085-555-5551', isPrimary: true },
|
||||
{ id: 'ct-10', customerId: 'cus-5', name: 'Palm J.', position: 'Plant Director', email: 'palm@lannasolar.co.th', phone: '085-555-5552' }
|
||||
],
|
||||
contactShares: [
|
||||
{ id: 'share-1', contactId: 'ct-1', sharedWithUserId: 'u-1', sharedWithUserName: 'Nicha P.', permission: 'view', createdAt: '2026-06-01T09:00:00.000Z' },
|
||||
{ id: 'share-2', contactId: 'ct-7', sharedWithUserId: 'u-2', sharedWithUserName: 'Ton A.', permission: 'edit', createdAt: '2026-06-03T10:30:00.000Z' }
|
||||
],
|
||||
contactShareLogs: [
|
||||
{ id: 'share-log-1', contactId: 'ct-1', action: 'SHARE', targetUserName: 'Nicha P.', actorName: 'Krit S.', createdAt: '2026-06-01T09:00:00.000Z' },
|
||||
{ id: 'share-log-2', contactId: 'ct-7', action: 'SHARE', targetUserName: 'Ton A.', actorName: 'Krit S.', createdAt: '2026-06-03T10:30:00.000Z' },
|
||||
{ id: 'share-log-3', contactId: 'ct-7', action: 'REVOKE', targetUserName: 'Ton A.', actorName: 'Nicha P.', createdAt: '2026-06-07T14:45:00.000Z' }
|
||||
],
|
||||
enquiries: [
|
||||
{
|
||||
id: 'enq-1',
|
||||
code: 'ENQ2606-001',
|
||||
title: 'Dock leveler replacement for Phase 3 warehouse',
|
||||
customerId: 'cus-1',
|
||||
contactId: 'ct-1',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'converted',
|
||||
requirementSummary: 'Replace 8 dock levelers with high cycle hydraulic model.',
|
||||
projectLocation: 'Bangkok Logistics Park',
|
||||
chancePercent: 82,
|
||||
competitor: 'Apex Dock',
|
||||
expectedValue: 2750000,
|
||||
dueDate: '2026-06-14',
|
||||
createdAt: '2026-05-28T09:00:00.000Z',
|
||||
updatedAt: '2026-06-09T15:20:00.000Z',
|
||||
quotationIds: ['qt-1', 'qt-2'],
|
||||
activities: [
|
||||
{ id: 'enq-1-act-1', type: 'CALL', title: 'รับ requirement เบื้องต้น', detail: 'ลูกค้าต้องการเปลี่ยนภายใน Q3', actorName: 'Krit S.', createdAt: '2026-05-28T09:00:00.000Z' },
|
||||
{ id: 'enq-1-act-2', type: 'VISIT', title: 'สำรวจหน้างาน', detail: 'หน้างานพร้อม shutdown 3 วัน', actorName: 'Krit S.', createdAt: '2026-05-30T13:30:00.000Z' },
|
||||
{ id: 'enq-1-act-3', type: 'CONVERT', title: 'Convert to quotation', detail: 'สร้าง QT2606-001', actorName: 'Nicha P.', createdAt: '2026-06-02T08:15:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-2',
|
||||
code: 'ENQ2606-002',
|
||||
title: 'Overhead crane upgrade for fabrication line',
|
||||
customerId: 'cus-2',
|
||||
contactId: 'ct-3',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'crane',
|
||||
status: 'requirement',
|
||||
requirementSummary: '10 ton overhead crane with remote monitoring.',
|
||||
projectLocation: 'Samut Sakhon Plant',
|
||||
chancePercent: 68,
|
||||
competitor: 'LiftPro',
|
||||
expectedValue: 5200000,
|
||||
dueDate: '2026-06-20',
|
||||
createdAt: '2026-05-29T10:00:00.000Z',
|
||||
updatedAt: '2026-06-10T11:00:00.000Z',
|
||||
quotationIds: ['qt-3'],
|
||||
activities: [
|
||||
{ id: 'enq-2-act-1', type: 'MEETING', title: 'Kickoff meeting', detail: 'หารือ requirement ทางเทคนิค', actorName: 'Krit S.', createdAt: '2026-05-29T10:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-3',
|
||||
code: 'ENQ2606-003',
|
||||
title: 'Solar rooftop for cold storage lot C',
|
||||
customerId: 'cus-3',
|
||||
contactId: 'ct-5',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'solarcell',
|
||||
status: 'follow_up',
|
||||
requirementSummary: '500 kWp rooftop with energy monitoring.',
|
||||
projectLocation: 'Chiang Mai DC',
|
||||
chancePercent: 49,
|
||||
competitor: 'SunNorth',
|
||||
expectedValue: 6100000,
|
||||
dueDate: '2026-06-18',
|
||||
createdAt: '2026-05-27T09:20:00.000Z',
|
||||
updatedAt: '2026-06-08T16:10:00.000Z',
|
||||
quotationIds: ['qt-4'],
|
||||
activities: [
|
||||
{ id: 'enq-3-act-1', type: 'NOTE', title: 'ติดตาม BOQ', detail: 'รอฝั่งลูกค้าส่งโหลดไฟฟ้า', actorName: 'Ton A.', createdAt: '2026-06-08T16:10:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-4',
|
||||
code: 'ENQ2606-004',
|
||||
title: 'Dock shelter package for retrofit',
|
||||
customerId: 'cus-4',
|
||||
contactId: 'ct-7',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'new',
|
||||
requirementSummary: 'Retrofit existing dock shelter 12 bays.',
|
||||
projectLocation: 'Bangna Logistics Hub',
|
||||
chancePercent: 35,
|
||||
competitor: 'Apex Dock',
|
||||
expectedValue: 1850000,
|
||||
dueDate: '2026-06-21',
|
||||
createdAt: '2026-06-05T14:00:00.000Z',
|
||||
updatedAt: '2026-06-05T14:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-4-act-1', type: 'CALL', title: 'Lead intake', detail: 'รับ lead จาก consultant', actorName: 'Krit S.', createdAt: '2026-06-05T14:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-5',
|
||||
code: 'ENQ2606-005',
|
||||
title: 'Crane preventive maintenance agreement',
|
||||
customerId: 'cus-2',
|
||||
contactId: 'ct-4',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'crane',
|
||||
status: 'qualifying',
|
||||
requirementSummary: 'Annual PM contract for 6 cranes.',
|
||||
projectLocation: 'Ayutthaya Plant',
|
||||
chancePercent: 53,
|
||||
competitor: 'LiftPro',
|
||||
expectedValue: 980000,
|
||||
dueDate: '2026-06-19',
|
||||
createdAt: '2026-06-02T11:00:00.000Z',
|
||||
updatedAt: '2026-06-06T12:00:00.000Z',
|
||||
quotationIds: ['qt-5'],
|
||||
activities: [
|
||||
{ id: 'enq-5-act-1', type: 'UPDATE', title: 'ผ่านขั้น qualifying', detail: 'ลูกค้ามี budget แล้ว', actorName: 'Krit S.', createdAt: '2026-06-06T12:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-6',
|
||||
code: 'ENQ2606-006',
|
||||
title: 'Solar canopy concept study',
|
||||
customerId: 'cus-5',
|
||||
contactId: 'ct-9',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'solarcell',
|
||||
status: 'closed_lost',
|
||||
requirementSummary: 'Concept study for parking canopy solar.',
|
||||
projectLocation: 'Chiang Rai Service Center',
|
||||
chancePercent: 0,
|
||||
competitor: 'GreenBeam',
|
||||
expectedValue: 2400000,
|
||||
dueDate: '2026-06-08',
|
||||
createdAt: '2026-05-20T09:00:00.000Z',
|
||||
updatedAt: '2026-06-08T18:00:00.000Z',
|
||||
quotationIds: ['qt-6'],
|
||||
activities: [
|
||||
{ id: 'enq-6-act-1', type: 'UPDATE', title: 'Lost to competitor', detail: 'ราคา competitor ต่ำกว่า', actorName: 'Ton A.', createdAt: '2026-06-08T18:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-7',
|
||||
code: 'ENQ2606-007',
|
||||
title: 'High-speed door package',
|
||||
customerId: 'cus-1',
|
||||
contactId: 'ct-2',
|
||||
branchId: 'bkk',
|
||||
salesmanId: 'sp-1',
|
||||
productType: 'dockdoor',
|
||||
status: 'cancelled',
|
||||
requirementSummary: 'High-speed doors for food-grade area.',
|
||||
projectLocation: 'Pathum Thani',
|
||||
chancePercent: 0,
|
||||
competitor: 'FastDoor',
|
||||
expectedValue: 1600000,
|
||||
dueDate: '2026-06-11',
|
||||
createdAt: '2026-05-25T10:10:00.000Z',
|
||||
updatedAt: '2026-06-04T11:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-7-act-1', type: 'UPDATE', title: 'Project cancelled', detail: 'Owner เลื่อน CAPEX', actorName: 'Nicha P.', createdAt: '2026-06-04T11:00:00.000Z' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enq-8',
|
||||
code: 'ENQ2606-008',
|
||||
title: 'Monorail crane for packaging line',
|
||||
customerId: 'cus-3',
|
||||
contactId: 'ct-6',
|
||||
branchId: 'chn',
|
||||
salesmanId: 'sp-3',
|
||||
productType: 'crane',
|
||||
status: 'new',
|
||||
requirementSummary: '1 ton monorail crane with quick delivery.',
|
||||
projectLocation: 'Lamphun Plant',
|
||||
chancePercent: 28,
|
||||
competitor: 'NorthHoist',
|
||||
expectedValue: 740000,
|
||||
dueDate: '2026-06-24',
|
||||
createdAt: '2026-06-09T13:00:00.000Z',
|
||||
updatedAt: '2026-06-09T13:00:00.000Z',
|
||||
quotationIds: [],
|
||||
activities: [
|
||||
{ id: 'enq-8-act-1', type: 'CALL', title: 'รับ enquiry ใหม่', detail: 'ต้องการเสนอราคาใน 7 วัน', actorName: 'Ton A.', createdAt: '2026-06-09T13:00:00.000Z' }
|
||||
]
|
||||
}
|
||||
],
|
||||
quotations: [
|
||||
{
|
||||
id: 'qt-1',
|
||||
code: 'QT2606-001',
|
||||
enquiryId: 'enq-1',
|
||||
quotationDate: '2026-06-02',
|
||||
validUntil: '2026-07-02',
|
||||
quotationType: 'official',
|
||||
project: 'Warehouse Dock Modernization',
|
||||
siteLocation: 'Bangkok Logistics Park',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
|
||||
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' },
|
||||
{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'pending_approval',
|
||||
revision: 0,
|
||||
totalAmount: 2750000,
|
||||
chancePercent: 82,
|
||||
isHotProject: true,
|
||||
approvalSnapshot: 'Awaiting Level 2 approval',
|
||||
items: [
|
||||
{ id: 'qt-1-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
|
||||
{ id: 'qt-1-item-2', topic: 'Installation', description: 'Site install and commissioning', quantity: 1, unit: 'lot', unitPrice: 510000, amount: 510000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-1-topic-1', type: 'scope', label: 'Scope', items: ['Supply equipment', 'Install and test', 'Operator training'] },
|
||||
{ id: 'qt-1-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Civil work by customer', 'Night shift overtime'] },
|
||||
{ id: 'qt-1-topic-3', type: 'payment', label: 'Payment', items: ['40% down payment', '50% upon delivery', '10% after handover'] }
|
||||
],
|
||||
followUps: [
|
||||
{ id: 'qt-1-fu-1', title: 'Prepare approval summary', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' },
|
||||
{ id: 'qt-1-fu-2', title: 'Confirm shutdown window', dueDate: '2026-06-13', ownerName: 'Krit S.', status: 'open' }
|
||||
],
|
||||
attachments: [
|
||||
{ id: 'qt-1-att-1', fileName: 'technical-spec.pdf', fileType: 'pdf', uploadedAt: '2026-06-02T10:00:00.000Z' }
|
||||
],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-1-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-03T09:30:00.000Z' },
|
||||
{ id: 'qt-1-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'pending' }
|
||||
],
|
||||
auditEventIds: ['audit-1', 'audit-2']
|
||||
},
|
||||
{
|
||||
id: 'qt-2',
|
||||
code: 'QT2606-001-R01',
|
||||
enquiryId: 'enq-1',
|
||||
quotationDate: '2026-06-07',
|
||||
validUntil: '2026-07-07',
|
||||
quotationType: 'official',
|
||||
project: 'Warehouse Dock Modernization',
|
||||
siteLocation: 'Bangkok Logistics Park',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-1', contactId: 'ct-1' },
|
||||
{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'revised',
|
||||
revision: 1,
|
||||
totalAmount: 2875000,
|
||||
chancePercent: 78,
|
||||
isHotProject: true,
|
||||
approvedPdfUrl: '/mock/qt2606-001-r01.pdf',
|
||||
items: [
|
||||
{ id: 'qt-2-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 },
|
||||
{ id: 'qt-2-item-2', topic: 'Safety', description: 'Additional dock light and safety package', quantity: 1, unit: 'lot', unitPrice: 635000, amount: 635000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-2-topic-1', type: 'scope', label: 'Scope', items: ['Updated safety package', 'Delivery within 30 days'] },
|
||||
{ id: 'qt-2-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Permit by customer'] },
|
||||
{ id: 'qt-2-topic-3', type: 'payment', label: 'Payment', items: ['40/50/10 milestone'] }
|
||||
],
|
||||
followUps: [
|
||||
{ id: 'qt-2-fu-1', title: 'Send revised file to customer', dueDate: '2026-06-12', ownerName: 'Krit S.', status: 'open' }
|
||||
],
|
||||
attachments: [
|
||||
{ id: 'qt-2-att-1', fileName: 'rev1-comparison.pdf', fileType: 'pdf', uploadedAt: '2026-06-07T16:00:00.000Z' }
|
||||
],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-2-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-07T15:30:00.000Z' },
|
||||
{ id: 'qt-2-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'approved', actedAt: '2026-06-08T09:00:00.000Z' }
|
||||
],
|
||||
auditEventIds: ['audit-3']
|
||||
},
|
||||
{
|
||||
id: 'qt-3',
|
||||
code: 'QT2606-002',
|
||||
enquiryId: 'enq-2',
|
||||
quotationDate: '2026-06-05',
|
||||
validUntil: '2026-07-05',
|
||||
quotationType: 'budgetary',
|
||||
project: 'Fabrication Crane Upgrade',
|
||||
siteLocation: 'Samut Sakhon Plant',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' },
|
||||
{ role: 'contractor', customerId: 'cus-4', contactId: 'ct-8' }
|
||||
],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: 5200000,
|
||||
chancePercent: 68,
|
||||
isHotProject: false,
|
||||
items: [
|
||||
{ id: 'qt-3-item-1', topic: 'Crane', description: '10 ton overhead crane', quantity: 1, unit: 'set', unitPrice: 4500000, amount: 4500000 },
|
||||
{ id: 'qt-3-item-2', topic: 'IoT', description: 'Remote monitoring package', quantity: 1, unit: 'set', unitPrice: 700000, amount: 700000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-3-topic-1', type: 'scope', label: 'Scope', items: ['Supply', 'Install', 'Commission'] },
|
||||
{ id: 'qt-3-topic-2', type: 'payment', label: 'Payment', items: ['50% deposit', '40% delivery', '10% handover'] },
|
||||
{ id: 'qt-3-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building strengthening'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-3-fu-1', title: 'Finalize civil scope', dueDate: '2026-06-15', ownerName: 'Krit S.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-4',
|
||||
code: 'QT2606-003',
|
||||
enquiryId: 'enq-3',
|
||||
quotationDate: '2026-06-03',
|
||||
validUntil: '2026-07-03',
|
||||
quotationType: 'official',
|
||||
project: 'Cold Chain Rooftop Solar',
|
||||
siteLocation: 'Chiang Mai DC',
|
||||
customerRoles: [
|
||||
{ role: 'owner', customerId: 'cus-3', contactId: 'ct-5' },
|
||||
{ role: 'consultant', customerId: 'cus-5', contactId: 'ct-9' }
|
||||
],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'sent',
|
||||
revision: 0,
|
||||
totalAmount: 6100000,
|
||||
chancePercent: 49,
|
||||
isHotProject: true,
|
||||
approvedPdfUrl: '/mock/qt2606-003.pdf',
|
||||
items: [
|
||||
{ id: 'qt-4-item-1', topic: 'Solar', description: '500 kWp rooftop system', quantity: 1, unit: 'lot', unitPrice: 5900000, amount: 5900000 },
|
||||
{ id: 'qt-4-item-2', topic: 'Monitoring', description: 'EMS dashboard', quantity: 1, unit: 'lot', unitPrice: 200000, amount: 200000 }
|
||||
],
|
||||
topics: [
|
||||
{ id: 'qt-4-topic-1', type: 'scope', label: 'Scope', items: ['EPC turnkey'] },
|
||||
{ id: 'qt-4-topic-2', type: 'payment', label: 'Payment', items: ['30/60/10'] },
|
||||
{ id: 'qt-4-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Transformer upgrade'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-4-fu-1', title: 'Follow up board decision', dueDate: '2026-06-11', ownerName: 'Ton A.', status: 'open' }],
|
||||
attachments: [{ id: 'qt-4-att-1', fileName: 'financial-model.xls', fileType: 'xls', uploadedAt: '2026-06-03T12:00:00.000Z' }],
|
||||
approvalSteps: [
|
||||
{ id: 'qt-4-ap-1', level: 1, approverName: 'Regional Sales Manager', approverPosition: 'Regional Manager', status: 'approved', actedAt: '2026-06-03T11:00:00.000Z' }
|
||||
],
|
||||
auditEventIds: ['audit-4']
|
||||
},
|
||||
{
|
||||
id: 'qt-5',
|
||||
code: 'QT2606-004',
|
||||
enquiryId: 'enq-5',
|
||||
quotationDate: '2026-06-06',
|
||||
validUntil: '2026-07-06',
|
||||
quotationType: 'service',
|
||||
project: 'Annual Crane PM',
|
||||
siteLocation: 'Ayutthaya Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-4' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'approved',
|
||||
revision: 0,
|
||||
totalAmount: 980000,
|
||||
chancePercent: 53,
|
||||
isHotProject: false,
|
||||
approvedPdfUrl: '/mock/qt2606-004.pdf',
|
||||
items: [{ id: 'qt-5-item-1', topic: 'Service', description: 'PM contract 12 months', quantity: 1, unit: 'lot', unitPrice: 980000, amount: 980000 }],
|
||||
topics: [
|
||||
{ id: 'qt-5-topic-1', type: 'scope', label: 'Scope', items: ['Quarterly PM', 'Emergency call support'] },
|
||||
{ id: 'qt-5-topic-2', type: 'payment', label: 'Payment', items: ['100% after PO'] },
|
||||
{ id: 'qt-5-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Spare parts'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-5-fu-1', title: 'Await PO', dueDate: '2026-06-17', ownerName: 'Nicha P.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-5-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-07T10:00:00.000Z' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-6',
|
||||
code: 'QT2606-005',
|
||||
enquiryId: 'enq-6',
|
||||
quotationDate: '2026-05-26',
|
||||
validUntil: '2026-06-25',
|
||||
quotationType: 'budgetary',
|
||||
project: 'Solar Canopy Feasibility',
|
||||
siteLocation: 'Chiang Rai Service Center',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-5', contactId: 'ct-9' }],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'lost',
|
||||
revision: 0,
|
||||
totalAmount: 2400000,
|
||||
chancePercent: 0,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-6-item-1', topic: 'Solar', description: 'Canopy concept and EPC budget', quantity: 1, unit: 'lot', unitPrice: 2400000, amount: 2400000 }],
|
||||
topics: [
|
||||
{ id: 'qt-6-topic-1', type: 'scope', label: 'Scope', items: ['Concept study'] },
|
||||
{ id: 'qt-6-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
|
||||
{ id: 'qt-6-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Authority permit'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: ['audit-5']
|
||||
},
|
||||
{
|
||||
id: 'qt-7',
|
||||
code: 'QT2606-006',
|
||||
enquiryId: 'enq-4',
|
||||
quotationDate: '2026-06-09',
|
||||
validUntil: '2026-07-09',
|
||||
quotationType: 'official',
|
||||
project: 'Dock Shelter Retrofit',
|
||||
siteLocation: 'Bangna Logistics Hub',
|
||||
customerRoles: [{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'pending_approval',
|
||||
revision: 0,
|
||||
totalAmount: 1850000,
|
||||
chancePercent: 35,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-7-item-1', topic: 'Retrofit', description: 'Dock shelter 12 bays', quantity: 12, unit: 'bay', unitPrice: 154166.67, amount: 1850000 }],
|
||||
topics: [
|
||||
{ id: 'qt-7-topic-1', type: 'scope', label: 'Scope', items: ['Supply and install 12 shelters'] },
|
||||
{ id: 'qt-7-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] },
|
||||
{ id: 'qt-7-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Scaffold by others'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-7-fu-1', title: 'Submit approval', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' }],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-7-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-8',
|
||||
code: 'QT2606-007',
|
||||
enquiryId: 'enq-8',
|
||||
quotationDate: '2026-06-10',
|
||||
validUntil: '2026-07-10',
|
||||
quotationType: 'official',
|
||||
project: 'Monorail Crane Fast Track',
|
||||
siteLocation: 'Lamphun Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-3', contactId: 'ct-6' }],
|
||||
salesmanId: 'sp-3',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'chn',
|
||||
status: 'draft',
|
||||
revision: 0,
|
||||
totalAmount: 740000,
|
||||
chancePercent: 28,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-8-item-1', topic: 'Crane', description: '1 ton monorail crane', quantity: 1, unit: 'set', unitPrice: 740000, amount: 740000 }],
|
||||
topics: [
|
||||
{ id: 'qt-8-topic-1', type: 'scope', label: 'Scope', items: ['Supply only'] },
|
||||
{ id: 'qt-8-topic-2', type: 'payment', label: 'Payment', items: ['100% before delivery'] },
|
||||
{ id: 'qt-8-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Install by customer'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-9',
|
||||
code: 'QT2606-008',
|
||||
enquiryId: 'enq-2',
|
||||
quotationDate: '2026-06-08',
|
||||
validUntil: '2026-07-08',
|
||||
quotationType: 'official',
|
||||
project: 'Crane Upgrade Option B',
|
||||
siteLocation: 'Samut Sakhon Plant',
|
||||
customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'rejected',
|
||||
revision: 0,
|
||||
totalAmount: 5450000,
|
||||
chancePercent: 22,
|
||||
isHotProject: false,
|
||||
items: [{ id: 'qt-9-item-1', topic: 'Crane', description: 'Higher spec option', quantity: 1, unit: 'set', unitPrice: 5450000, amount: 5450000 }],
|
||||
topics: [
|
||||
{ id: 'qt-9-topic-1', type: 'scope', label: 'Scope', items: ['Higher duty cycle spec'] },
|
||||
{ id: 'qt-9-topic-2', type: 'payment', label: 'Payment', items: ['40/50/10'] },
|
||||
{ id: 'qt-9-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building modification'] }
|
||||
],
|
||||
followUps: [],
|
||||
attachments: [],
|
||||
approvalSteps: [{ id: 'qt-9-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'rejected', actedAt: '2026-06-09T13:00:00.000Z', comment: 'Margin below target' }],
|
||||
auditEventIds: []
|
||||
},
|
||||
{
|
||||
id: 'qt-10',
|
||||
code: 'QT2606-009',
|
||||
enquiryId: 'enq-5',
|
||||
quotationDate: '2026-06-10',
|
||||
validUntil: '2026-07-10',
|
||||
quotationType: 'service',
|
||||
project: 'Spare Parts Bundle',
|
||||
siteLocation: 'Ayutthaya Plant',
|
||||
customerRoles: [{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }],
|
||||
salesmanId: 'sp-1',
|
||||
saleAdminId: 'sp-2',
|
||||
branchId: 'bkk',
|
||||
status: 'accepted',
|
||||
revision: 0,
|
||||
totalAmount: 420000,
|
||||
chancePercent: 100,
|
||||
isHotProject: false,
|
||||
approvedPdfUrl: '/mock/qt2606-009.pdf',
|
||||
items: [{ id: 'qt-10-item-1', topic: 'Spare Parts', description: 'Critical spare bundle', quantity: 1, unit: 'lot', unitPrice: 420000, amount: 420000 }],
|
||||
topics: [
|
||||
{ id: 'qt-10-topic-1', type: 'scope', label: 'Scope', items: ['Spare bundle supply'] },
|
||||
{ id: 'qt-10-topic-2', type: 'payment', label: 'Payment', items: ['100% with PO'] },
|
||||
{ id: 'qt-10-topic-3', type: 'exclusion', label: 'Exclusion', items: ['On-site labor'] }
|
||||
],
|
||||
followUps: [{ id: 'qt-10-fu-1', title: 'Coordinate delivery slot', dueDate: '2026-06-16', ownerName: 'Nicha P.', status: 'done' }],
|
||||
attachments: [{ id: 'qt-10-att-1', fileName: 'spare-list.doc', fileType: 'doc', uploadedAt: '2026-06-10T09:00:00.000Z' }],
|
||||
approvalSteps: [{ id: 'qt-10-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-10T08:00:00.000Z' }],
|
||||
auditEventIds: []
|
||||
}
|
||||
],
|
||||
approvals: [
|
||||
{ id: 'approval-1', quotationId: 'qt-1', quotationCode: 'QT2606-001', amount: 2750000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 2, status: 'pending' },
|
||||
{ id: 'approval-2', quotationId: 'qt-7', quotationCode: 'QT2606-006', amount: 1850000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 1, status: 'pending' },
|
||||
{ id: 'approval-3', quotationId: 'qt-3', quotationCode: 'QT2606-002', amount: 5200000, productType: 'crane', branchId: 'bkk', submitterName: 'Krit S.', approverLevel: 1, status: 'pending' }
|
||||
],
|
||||
auditEvents: [
|
||||
{ id: 'audit-1', entityType: 'quotation', entityId: 'qt-1', action: 'CREATE', actorName: 'Nicha P.', detail: 'สร้างใบเสนอราคา QT2606-001', createdAt: '2026-06-02T08:15:00.000Z' },
|
||||
{ id: 'audit-2', entityType: 'approval', entityId: 'qt-1', action: 'APPROVE', actorName: 'Head of Sales', detail: 'อนุมัติ level 1', createdAt: '2026-06-03T09:30:00.000Z' },
|
||||
{ id: 'audit-3', entityType: 'quotation', entityId: 'qt-2', action: 'REVISE', actorName: 'Krit S.', detail: 'สร้าง revision R01 เพิ่ม safety package', createdAt: '2026-06-07T16:00:00.000Z' },
|
||||
{ id: 'audit-4', entityType: 'quotation', entityId: 'qt-4', action: 'SEND', actorName: 'Ton A.', detail: 'ส่งใบเสนอราคาให้ลูกค้าทางอีเมล', createdAt: '2026-06-04T10:10:00.000Z' },
|
||||
{ id: 'audit-5', entityType: 'quotation', entityId: 'qt-6', action: 'REJECT', actorName: 'Customer Board', detail: 'ลูกค้าเลือกคู่แข่งเนื่องจากราคา', createdAt: '2026-06-08T18:00:00.000Z' }
|
||||
],
|
||||
sequences: [
|
||||
{ id: 'seq-1', documentType: 'enquiry', prefix: 'ENQ', period: '2606', branchId: 'bkk', currentNumber: 8, paddingLength: 3 },
|
||||
{ id: 'seq-2', documentType: 'quotation', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 9, paddingLength: 3 },
|
||||
{ id: 'seq-3', documentType: 'quotation_revision', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 1, paddingLength: 2 }
|
||||
],
|
||||
masterOptions: [
|
||||
{ id: 'mo-1', group: 'status', code: 'new', label: 'New', active: true },
|
||||
{ id: 'mo-2', group: 'status', code: 'pending_approval', label: 'Pending Approval', active: true },
|
||||
{ id: 'mo-3', group: 'product_type', code: 'crane', label: 'Crane', active: true },
|
||||
{ id: 'mo-4', group: 'product_type', code: 'dockdoor', label: 'Dock Door', active: true },
|
||||
{ id: 'mo-5', group: 'product_type', code: 'solarcell', label: 'Solar Cell', active: true },
|
||||
{ id: 'mo-6', group: 'payment_term', code: '40_50_10', label: '40/50/10', active: true },
|
||||
{ id: 'mo-7', group: 'currency', code: 'THB', label: 'Thai Baht', active: true },
|
||||
{ id: 'mo-8', group: 'tax_rate', code: 'VAT7', label: 'VAT 7%', active: true }
|
||||
],
|
||||
templates: [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: 'Standard Commercial Proposal',
|
||||
version: 'v1.2',
|
||||
branchId: 'bkk',
|
||||
description: 'Template for standard equipment quotation.',
|
||||
mappings: [
|
||||
{ id: 'tpl-1-map-1', key: 'customer_name', placeholder: '{{customer_name}}', sourceField: 'customer.name' },
|
||||
{ id: 'tpl-1-map-2', key: 'project_name', placeholder: '{{project_name}}', sourceField: 'quotation.project' },
|
||||
{ id: 'tpl-1-map-3', key: 'quotation_price', placeholder: '{{quotation_price}}', sourceField: 'quotation.totalAmount' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: 'Solar EPC Proposal',
|
||||
version: 'v0.9',
|
||||
branchId: 'chn',
|
||||
description: 'Template for solar EPC quotation preview.',
|
||||
mappings: [
|
||||
{ id: 'tpl-2-map-1', key: 'site_location', placeholder: '{{site_location}}', sourceField: 'quotation.siteLocation' },
|
||||
{ id: 'tpl-2-map-2', key: 'approver_names', placeholder: '{{approver_names}}', sourceField: 'quotation.approvalSteps[].approverName' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
export const enquiryStatusOptions = [
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'qualifying', label: 'Qualifying' },
|
||||
{ value: 'requirement', label: 'Requirement' },
|
||||
{ value: 'follow_up', label: 'Follow Up' },
|
||||
{ value: 'converted', label: 'Converted' },
|
||||
{ value: 'closed_lost', label: 'Closed Lost' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
] as const;
|
||||
|
||||
export const quotationStatusOptions = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'pending_approval', label: 'Pending Approval' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'sent', label: 'Sent' },
|
||||
{ value: 'accepted', label: 'Accepted' },
|
||||
{ value: 'lost', label: 'Lost' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
{ value: 'revised', label: 'Revised' }
|
||||
] as const;
|
||||
|
||||
export const customerStatusOptions = [
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'prospect', label: 'Prospect' },
|
||||
{ value: 'inactive', label: 'Inactive' }
|
||||
] as const;
|
||||
|
||||
export const productTypeOptions = [
|
||||
{ value: 'crane', label: 'Crane' },
|
||||
{ value: 'dockdoor', label: 'Dock Door' },
|
||||
{ value: 'solarcell', label: 'Solar Cell' }
|
||||
] as const;
|
||||
|
||||
export const quotationTypeOptions = [
|
||||
{ value: 'official', label: 'Official' },
|
||||
{ value: 'budgetary', label: 'Budgetary' },
|
||||
{ value: 'service', label: 'Service' }
|
||||
] as const;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const customerSchema = z.object({
|
||||
code: z.string().min(3),
|
||||
name: z.string().min(3),
|
||||
taxId: z.string().min(8),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(8),
|
||||
customerType: z.enum(['developer', 'contractor', 'owner', 'consultant']),
|
||||
customerStatus: z.enum(['active', 'prospect', 'inactive']),
|
||||
branchId: z.string().min(1),
|
||||
address: z.string().min(3),
|
||||
province: z.string().min(2),
|
||||
district: z.string().min(2),
|
||||
subDistrict: z.string().min(2),
|
||||
postalCode: z.string().min(4)
|
||||
});
|
||||
|
||||
export type CustomerFormValues = z.infer<typeof customerSchema>;
|
||||
@@ -1,19 +0,0 @@
|
||||
export function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat('th-TH', {
|
||||
style: 'currency',
|
||||
currency: 'THB',
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('th-TH', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${value}%`;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Customer, EnquiryStatus, QuotationStatus } from '../api/types';
|
||||
|
||||
export function getCustomerStatusLabel(status: Customer['customerStatus']) {
|
||||
return (
|
||||
{
|
||||
active: 'Active',
|
||||
prospect: 'Prospect',
|
||||
inactive: 'Inactive'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnquiryStatusLabel(status: EnquiryStatus) {
|
||||
return (
|
||||
{
|
||||
new: 'New',
|
||||
qualifying: 'Qualifying',
|
||||
requirement: 'Requirement',
|
||||
follow_up: 'Follow Up',
|
||||
converted: 'Converted',
|
||||
closed_lost: 'Closed Lost',
|
||||
cancelled: 'Cancelled'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
|
||||
export function getQuotationStatusLabel(status: QuotationStatus) {
|
||||
return (
|
||||
{
|
||||
draft: 'Draft',
|
||||
pending_approval: 'Pending Approval',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
sent: 'Sent',
|
||||
accepted: 'Accepted',
|
||||
lost: 'Lost',
|
||||
cancelled: 'Cancelled',
|
||||
revised: 'Revised'
|
||||
}[status] ?? status
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user