diff --git a/backend/api_gateway/src/controllers/invoiceController.ts b/backend/api_gateway/src/controllers/invoiceController.ts
index 3bf0987f..f9262006 100644
--- a/backend/api_gateway/src/controllers/invoiceController.ts
+++ b/backend/api_gateway/src/controllers/invoiceController.ts
@@ -29,7 +29,11 @@ export const getAllInvoices = async (
throw new Error('User not authenticated');
}
const token = req.headers.authorization?.split(' ')[1] || '';
- const invoices = await invoiceAggregationService.getAllInvoices(user, token);
+ const invoices = await invoiceAggregationService.getAllInvoices(
+ user,
+ token,
+ req.query as Record,
+ );
return res.status(200).json({
success: true,
data: invoices,
@@ -185,6 +189,25 @@ export const financeApproveQuotation = async (req: Request, res: Response, next:
/**
* Employee converts an approved quotation into an active Sale/Rent/Lease transaction.
*/
+export const confirmServiceEstimateToAccounts = async (
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => {
+ try {
+ const id = req.params.id as string;
+ const token = req.headers.authorization?.split(' ')[1] || '';
+ const invoice = await invoiceAggregationService.confirmServiceEstimateToAccounts(id, token);
+ return res.status(200).json({
+ success: true,
+ data: invoice,
+ message: 'Service estimate taken into accounts — receivable raised.',
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
diff --git a/backend/api_gateway/src/routes/invoiceRoutes.ts b/backend/api_gateway/src/routes/invoiceRoutes.ts
index a8375ab8..2c23b2b8 100644
--- a/backend/api_gateway/src/routes/invoiceRoutes.ts
+++ b/backend/api_gateway/src/routes/invoiceRoutes.ts
@@ -38,6 +38,7 @@ import {
processReturn,
financeApproveQuotation,
convertToTransaction,
+ confirmServiceEstimateToAccounts,
createDirectSale,
createQuotationTemplate,
getQuotationTemplates,
@@ -269,6 +270,16 @@ router.post(
convertToTransaction,
);
+/**
+ * Accounts confirms a customer-accepted service estimate into the books, raising the
+ * receivable. Finance/Admin only — it is a bookkeeping act, not a service-desk one.
+ */
+router.post(
+ '/:id/confirm-service-to-accounts',
+ requireRole(UserRole.ADMIN, UserRole.FINANCE),
+ confirmServiceEstimateToAccounts,
+);
+
/**
* Manager or Employee approval for a next-step action.
*/
diff --git a/backend/api_gateway/src/services/invoiceAggregationService.ts b/backend/api_gateway/src/services/invoiceAggregationService.ts
index 811f8a2e..25f4a2fb 100644
--- a/backend/api_gateway/src/services/invoiceAggregationService.ts
+++ b/backend/api_gateway/src/services/invoiceAggregationService.ts
@@ -247,12 +247,28 @@ export class InvoiceAggregationService {
async getAllInvoices(
user: { role: string; branchId?: string },
token: string,
+ /**
+ * The caller's own filters (billType, status, …).
+ *
+ * These used to stop here: the gateway read none of req.query, so billing was always
+ * asked for every invoice and the "service estimates awaiting approval" request came
+ * back with paid sales and refunds in it. Forwarding them is what makes the filter
+ * billing already applies actually reachable from the browser.
+ */
+ query?: Record,
): Promise {
try {
+ const forwarded: Record = {};
+ for (const key of ['billType', 'status', 'saleType', 'customerId'] as const) {
+ const v = query?.[key];
+ if (typeof v === 'string' && v.trim()) forwarded[key] = v.trim();
+ }
+
const billingResponse = await axios.get<{ data: Invoice[] }>(
`${BILLING_SERVICE_URL}/invoices`,
{
headers: { Authorization: `Bearer ${token}` },
+ params: forwarded,
},
);
let invoices = billingResponse.data.data;
@@ -833,6 +849,34 @@ export class InvoiceAggregationService {
/**
* Employee converts an approved quotation into a transaction.
*/
+ /** Accounts taking a customer-accepted service estimate into the books. */
+ async confirmServiceEstimateToAccounts(id: string, token: string) {
+ try {
+ const response = await axios.post<{ data: Invoice }>(
+ `${BILLING_SERVICE_URL}/invoices/${id}/confirm-service-to-accounts`,
+ {},
+ { headers: { Authorization: `Bearer ${token}` } },
+ );
+ return response.data.data;
+ } catch (error: unknown) {
+ // Pass billing's own status and message through. Without this the gateway turned a
+ // precise 400 ("this one is FINANCE_APPROVED, not customer-accepted") into a bare
+ // 500, which tells the person clicking nothing about why it was refused.
+ if (axios.isAxiosError(error)) {
+ logger.error('Axios error confirming service estimate to accounts', {
+ message: error.message,
+ responseStatus: error.response?.status,
+ responseData: error.response?.data,
+ });
+ throw new AppError(
+ error.response?.data?.message || 'Failed to take the estimate into accounts',
+ error.response?.status || 500,
+ );
+ }
+ throw new AppError('Internal Gateway Error confirming service estimate', 500);
+ }
+ }
+
async convertToTransaction(id: string, token: string) {
try {
const response = await axios.post<{ data: Invoice }>(
diff --git a/backend/billing_service/src/controllers/invoiceController.ts b/backend/billing_service/src/controllers/invoiceController.ts
index f4d0d809..b3d05fe8 100644
--- a/backend/billing_service/src/controllers/invoiceController.ts
+++ b/backend/billing_service/src/controllers/invoiceController.ts
@@ -303,6 +303,71 @@ export const financeApproveQuotation = async (req: Request, res: Response, next:
/**
* Employee converts an approved quotation into an active Sale/Rent/Lease transaction.
*/
+/**
+ * POST /invoices/:id/confirm-service-to-accounts
+ *
+ * Accounts taking a customer-accepted service estimate into the books.
+ */
+export const confirmServiceEstimateToAccounts = async (
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => {
+ try {
+ const id = req.params.id as string;
+ if (!req.user?.userId) throw new AppError('User context missing', 401);
+ const invoice = await billingService.confirmServiceEstimateToAccounts(id, req.user.userId);
+ return res.status(200).json({
+ success: true,
+ data: invoice,
+ message: 'Service estimate taken into accounts — receivable raised.',
+ });
+ } catch (error) {
+ next(error);
+ }
+};
+
+/**
+ * POST /invoices/:id/service-completion-payment
+ *
+ * Internal: called by ven_inv when a technician closes a job having taken payment.
+ */
+export const recordServiceCompletionPayment = async (
+ req: Request,
+ res: Response,
+ next: NextFunction,
+) => {
+ try {
+ const id = req.params.id as string;
+ const {
+ amount,
+ paymentMode,
+ accountId,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
+ remarks,
+ branchId,
+ collectedBy,
+ } = req.body;
+ const result = await billingService.recordServiceCompletionPayment({
+ invoiceId: id,
+ userId: collectedBy || req.user?.userId || 'SYSTEM',
+ amount: Number(amount) || 0,
+ paymentMode,
+ accountId,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
+ remarks,
+ branchId,
+ });
+ return res.status(201).json({ success: true, data: result });
+ } catch (error) {
+ next(error);
+ }
+};
+
export const convertToTransaction = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
@@ -496,9 +561,24 @@ export const getAllInvoices = async (req: Request, res: Response, next: NextFunc
try {
const branchId = req.user?.role === 'ADMIN' ? undefined : req.user?.branchId;
const invoices = await billingService.getAllInvoices(branchId);
+
+ // `?billType=` and `?status=` were accepted and then silently ignored: this handler
+ // never looked at req.query, so a caller asking for "service estimates awaiting
+ // finance approval" was handed every invoice in the branch — paid sales, refunds and
+ // all. The Finance estimates page only looked right because it re-filtered the same
+ // list again in the browser; anything trusting the URL got the wrong rows.
+ const billType = (req.query.billType as string | undefined)?.trim();
+ const status = (req.query.status as string | undefined)?.trim();
+
+ const filtered = invoices.filter((inv) => {
+ if (billType && inv.billType !== billType) return false;
+ if (status && inv.status !== status) return false;
+ return true;
+ });
+
return res.status(200).json({
success: true,
- data: invoices,
+ data: filtered,
});
} catch (error) {
next(error);
@@ -1611,6 +1691,7 @@ export const createServiceContractInvoice = async (
paymentDate: initialPayment.paymentDate,
referenceNumber: initialPayment.referenceNumber,
remarks: initialPayment.remarks,
+ cashAccountId: initialPayment.cashAccountId,
}
: undefined,
});
@@ -1689,6 +1770,11 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next
paymentMode,
accountId,
remarks,
+ collectedByName,
+ collectedByRole,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
} = req.body;
if (!serviceTicketId || !branchId) {
return res.status(400).json({
@@ -1696,7 +1782,7 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next
message: 'serviceTicketId and branchId are required',
});
}
- const invoice = await billingService.recordServiceVisitCharge({
+ const { invoice, paymentRequestId } = await billingService.recordServiceVisitCharge({
serviceTicketId,
ticketNumber,
customerId,
@@ -1706,8 +1792,15 @@ export const recordServiceVisitCharge = async (req: Request, res: Response, next
paymentMode,
accountId,
remarks,
+ collectedByName,
+ collectedByRole,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
});
- return res.status(201).json({ success: true, data: invoice });
+ // paymentRequestId is what the caller stores on the ticket so it can follow the
+ // approval; the invoice alone no longer tells you whether the money has moved.
+ return res.status(201).json({ success: true, data: { ...invoice, paymentRequestId } });
} catch (error) {
next(error);
}
diff --git a/backend/billing_service/src/controllers/saleWorkflowController.ts b/backend/billing_service/src/controllers/saleWorkflowController.ts
index 6d7b1d40..f86c354e 100644
--- a/backend/billing_service/src/controllers/saleWorkflowController.ts
+++ b/backend/billing_service/src/controllers/saleWorkflowController.ts
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from 'express';
+import { syncVisitChargeDecision } from '../utils/serviceTicketSync';
import {
renderReceipt,
PAGE as RECEIPT_PAGE,
@@ -2043,6 +2044,7 @@ function receiptContextLabel(request: SalePaymentRequest): string | undefined {
LEASE_ADVANCE: 'Lease — Advance',
LEASE_PERIODIC: 'Lease — Periodic Collection',
LEASE_SECURITY_DEPOSIT: 'Lease — Security Deposit',
+ SERVICE_VISIT_CHARGE: 'Service — Visit Charge',
};
return map[ctx] ?? ctx.replace(/_/g, ' ');
}
@@ -2578,6 +2580,23 @@ export const approveSalePayment = async (req: Request, res: Response, next: Next
}
await queryRunner.commitTransaction();
+
+ // Mirror the decision onto the service ticket so the desk stops showing "awaiting
+ // approval". After the commit on purpose: the money is posted either way, and this
+ // must never be able to roll the approval back.
+ if (request.paymentContext === 'SERVICE_VISIT_CHARGE') {
+ const inv = await Source.getRepository(Invoice).findOne({
+ where: { id: request.invoiceId },
+ });
+ if (inv?.serviceTicketId) {
+ await syncVisitChargeDecision({
+ serviceTicketId: inv.serviceTicketId,
+ status: 'COLLECTED',
+ paymentRequestId: request.id,
+ });
+ }
+ }
+
res.json({ success: true, data: { ...request, paymentTransactionId: savedTxn.id } });
} catch (err) {
await queryRunner.rollbackTransaction();
@@ -2615,6 +2634,22 @@ export const rejectSalePayment = async (req: Request, res: Response, next: NextF
request.rejectionReason = rejectionReason;
await repo.save(request);
+ // A rejected visit charge is still owed — the ticket returns to "collect" so the desk
+ // or the technician can take it again, rather than the charge quietly vanishing.
+ if (request.paymentContext === 'SERVICE_VISIT_CHARGE') {
+ const inv = await Source.getRepository(Invoice).findOne({
+ where: { id: request.invoiceId },
+ });
+ if (inv?.serviceTicketId) {
+ await syncVisitChargeDecision({
+ serviceTicketId: inv.serviceTicketId,
+ status: 'REJECTED',
+ paymentRequestId: request.id,
+ rejectionReason: rejectionReason ?? null,
+ });
+ }
+ }
+
res.json({ success: true, data: request });
} catch (err) {
next(err);
diff --git a/backend/billing_service/src/routes/invoiceRoutes.ts b/backend/billing_service/src/routes/invoiceRoutes.ts
index 4514246b..9abebbe3 100644
--- a/backend/billing_service/src/routes/invoiceRoutes.ts
+++ b/backend/billing_service/src/routes/invoiceRoutes.ts
@@ -15,6 +15,8 @@ import {
financeReject,
financeApproveQuotation,
convertToTransaction,
+ recordServiceCompletionPayment,
+ confirmServiceEstimateToAccounts,
generateFinalInvoice,
getAllInvoices,
getInvoiceById,
@@ -288,6 +290,20 @@ router.post(
*/
router.post('/:id/convert-to-transaction', authMiddleware, convertToTransaction);
+/** Internal: payment a technician collected at job completion. */
+router.post('/:id/service-completion-payment', authMiddleware, recordServiceCompletionPayment);
+
+/**
+ * Accounts confirming a customer-accepted service estimate into the books. Finance only —
+ * this raises a real receivable, which is a bookkeeping act, not a service-desk one.
+ */
+router.post(
+ '/:id/confirm-service-to-accounts',
+ authMiddleware,
+ requireRole(EmployeeRole.ADMIN, EmployeeRole.FINANCE),
+ confirmServiceEstimateToAccounts,
+);
+
/**
* Record a deposit or initial payment from the customer.
*/
diff --git a/backend/billing_service/src/services/billingService.ts b/backend/billing_service/src/services/billingService.ts
index c9afc525..2d6efbf3 100644
--- a/backend/billing_service/src/services/billingService.ts
+++ b/backend/billing_service/src/services/billingService.ts
@@ -44,6 +44,7 @@ import { BillType } from '../entities/enums/billType';
import { PaymentTiming } from '../entities/enums/paymentTiming';
import { getBranchCurrencyInfo, generatePaymentReference } from './billingHelpers';
import { createSalePaymentRequest } from './salePaymentRequestService';
+import { SalePaymentRequest } from '../entities/salePaymentRequestEntity';
const appendOpenEndedSlab = (
ranges: T[] | undefined,
excessRate: number | undefined,
@@ -1820,6 +1821,120 @@ export class BillingService {
/**
* Employee converts a finance-approved quotation into a transaction (Proforma).
*/
+ /**
+ * Accounts confirming a service estimate the customer has accepted.
+ *
+ * Up to this point the whole service job lives on a QUOTATION, and the accounting
+ * queries only ever count `type = 'FINAL'` or a PROFORMA that is ACTIVE_CONTRACT /
+ * INVOICED / PAID. So a customer could accept a 1,290 job, the technician could do the
+ * work, and the 1,290 would never appear as a receivable anywhere — the books simply
+ * never learned about it. Converting is what makes the money real:
+ *
+ * QUOTATION / CUSTOMER_ACCEPTED → PROFORMA / INVOICED (QTN-… renumbered to INV-…)
+ *
+ * Deliberately a separate, explicit act by Accounts rather than something that fires on
+ * the customer's signature: raising a receivable is a bookkeeping decision, and the
+ * person who owns the ledger should be the one who makes it.
+ */
+ async confirmServiceEstimateToAccounts(id: string, userId: string) {
+ const invoice = await this.invoiceRepo.findById(id);
+ if (!invoice) throw new AppError('Service estimate not found', 404);
+ if (invoice.billType !== BillType.SERVICE) {
+ throw new AppError('This endpoint only confirms service estimates', 400);
+ }
+ if (invoice.status !== InvoiceStatus.CUSTOMER_ACCEPTED) {
+ throw new AppError(
+ `Only a customer-accepted estimate can be taken into accounts (this one is ${invoice.status}).`,
+ 400,
+ );
+ }
+
+ // Reuse the existing conversion so the QTN→INV renumber and its audit entry stay in
+ // one place; it lands on DRAFT, which is still not a receivable, so finish the job.
+ const converted = await this.convertToTransaction(id, userId);
+
+ const repo = Source.getRepository(Invoice);
+ const row = await repo.findOne({ where: { id: converted.id } });
+ if (!row) throw new AppError('Invoice vanished during conversion', 500);
+ row.status = InvoiceStatus.INVOICED;
+ await repo.save(row);
+
+ await logAudit(
+ row.id,
+ 'STATUS_CHANGE',
+ userId,
+ 'Service estimate confirmed into accounts — receivable raised.',
+ InvoiceStatus.CUSTOMER_ACCEPTED,
+ InvoiceStatus.INVOICED,
+ );
+
+ return this.invoiceRepo.findById(row.id);
+ }
+
+ /**
+ * Money the technician collected when they finished the job.
+ *
+ * Completion used to collect nothing: the technician closed the ticket, the customer paid
+ * them on the spot, and the system had no idea — the invoice sat as an open receivable
+ * until somebody in Accounts noticed and keyed the payment in by hand. The cash in the
+ * technician's pocket was invisible until then.
+ *
+ * Two things have to happen together here. The estimate is still a QUOTATION at this
+ * point, and a quotation is not a receivable, so there is nothing for a payment to settle
+ * against — it is taken into accounts first. Then the collection is raised as a PENDING
+ * request like every other one: the technician took the money, Accounts decide whether it
+ * posts, and it lands in whichever cash/bank account they chose on the form.
+ */
+ async recordServiceCompletionPayment(payload: {
+ invoiceId: string;
+ userId: string;
+ amount: number;
+ paymentMode: string;
+ accountId?: string;
+ chequeNumber?: string;
+ chequeBankName?: string;
+ chequeDate?: string;
+ remarks?: string;
+ branchId: string;
+ }) {
+ const amount = Number(payload.amount) || 0;
+ if (amount <= 0) throw new AppError('Collected amount must be greater than zero', 400);
+
+ const repo = Source.getRepository(Invoice);
+ let invoice = await repo.findOne({ where: { id: payload.invoiceId } });
+ if (!invoice) throw new AppError('Service invoice not found', 404);
+
+ if (invoice.type === InvoiceType.QUOTATION) {
+ if (invoice.status !== InvoiceStatus.CUSTOMER_ACCEPTED) {
+ throw new AppError(
+ `The customer must accept the estimate before payment can be collected (this one is ${invoice.status}).`,
+ 400,
+ );
+ }
+ await this.confirmServiceEstimateToAccounts(invoice.id, payload.userId);
+ invoice = await repo.findOne({ where: { id: payload.invoiceId } });
+ if (!invoice) throw new AppError('Invoice vanished during conversion', 500);
+ }
+
+ const request = await createSalePaymentRequest({
+ invoiceId: invoice.id,
+ branchId: payload.branchId,
+ userId: payload.userId,
+ amount,
+ paymentMode: payload.paymentMode,
+ paymentDate: new Date(),
+ cashAccountId: payload.accountId,
+ chequeNumber: payload.chequeNumber,
+ chequeBankName: payload.chequeBankName,
+ chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ remarks: payload.remarks || `Service completion payment — ${invoice.invoiceNumber}`,
+ paymentContext: 'SERVICE_COMPLETION',
+ });
+
+ return { invoice, paymentRequestId: request.id, requestNo: request.requestNo };
+ }
+
async convertToTransaction(id: string, userId: string) {
const invoice = await this.invoiceRepo.findById(id);
if (!invoice) throw new AppError('Quotation not found', 404);
@@ -4679,13 +4794,33 @@ export class BillingService {
const finalTotal =
itemsTotal + (payload.visitChargeMethod === 'ADDED_TO_ESTIMATE' ? visitCharge : 0) - discount;
+ /**
+ * The customer's name, copied onto the invoice.
+ *
+ * Accounts Receivable reads `invoices.customer_name` — a denormalised column, not a
+ * join to CRM — so an invoice created without it shows as "Unknown Customer" on the
+ * AR page no matter how valid its customerId is. Every service estimate was landing
+ * there nameless.
+ */
+ const customer = payload.customerId
+ ? await this.getCustomerDetails(payload.customerId).catch(() => null)
+ : null;
+ const customerName =
+ (customer as { name?: string } | null)?.name ??
+ (customer as { customerName?: string } | null)?.customerName ??
+ null;
+
const invoice = invoiceRepo.create({
invoiceNumber,
customerId: payload.customerId || undefined,
+ customerName: customerName || undefined,
branchId: payload.branchId,
createdBy: payload.createdBy,
serviceTicketId: payload.serviceTicketId,
- saleType: payload.saleType as SaleType,
+ // A service job is a SERVICE sale, whatever the caller passed. ven_inv hard-coded
+ // 'PRODUCT_SALE' here, so every repair showed up in Accounts typed as a product
+ // sale — wrong in the AR list, and wrong in anything that segments revenue by type.
+ saleType: SaleType.SERVICE,
status: payload.status as InvoiceStatus,
billType: BillType.SERVICE,
visitChargeAmount: visitCharge,
@@ -4740,10 +4875,25 @@ export class BillingService {
const labourAmount = labour ? Number(labour.unitPrice || 0) * (labour.quantity || 1) : 0;
if (!labour || labourAmount <= 0) return invoice;
+ const waivedDescription = 'Labor Cost / Service Charge (waived — approved within validity)';
labour.unitPrice = 0;
- labour.description = 'Labor Cost / Service Charge (waived — approved within validity)';
+ labour.description = waivedDescription;
await itemRepo.save(labour);
+ // The same row, in the invoice's own loaded graph.
+ //
+ // Invoice.items is declared `cascade: true`, and `invoice` was loaded WITH its items.
+ // Saving the invoice below therefore re-writes every item from the in-memory array —
+ // which still held the original price. The item update above was being applied and
+ // then immediately overwritten, leaving an invoice whose total said 150 while its
+ // own lines still added up to 550. Zeroing it here too means the cascade writes the
+ // waiver instead of undoing it.
+ const labourInGraph = (invoice.items || []).find((it) => it.id === labour.id);
+ if (labourInGraph) {
+ labourInGraph.unitPrice = 0;
+ labourInGraph.description = waivedDescription;
+ }
+
invoice.totalAmount = Math.max(0, Number(invoice.totalAmount) - labourAmount);
const saved = await this.invoiceRepo.save(invoice);
@@ -4776,17 +4926,57 @@ export class BillingService {
accountId?: string;
/** Overrides the default "collected on-site by technician" audit text. */
remarks?: string;
- }): Promise {
+ /** Cheque details, required by the approval queue when paymentMode is CHEQUE. */
+ chequeNumber?: string;
+ chequeBankName?: string;
+ chequeDate?: string;
+ /** Name of the person who physically took the money, for the Accounts queue. */
+ collectedByName?: string;
+ /** SERVICE_HELP_DESK | SERVICE_TECHNICIAN — which desk took it. */
+ collectedByRole?: string;
+ }): Promise<{ invoice: Invoice; paymentRequestId: string }> {
const amount = Number(payload.amount) || 0;
if (amount <= 0) throw new AppError('Visit charge amount must be greater than zero', 400);
const invoiceRepo = Source.getRepository(Invoice);
const marker = `VISIT_CHARGE_ONSITE:${payload.serviceTicketId}`;
+ // Idempotent on the ticket: a retried call must not raise a SECOND charge, so the
+ // invoice is reused. The request is a different matter — a charge Accounts rejected is
+ // still owed, and collecting it again has to produce a new PENDING request. Returning
+ // the old rejected one would leave the desk showing "awaiting approval" against a
+ // request nobody will ever act on, and the money would never post.
+ const requestRepo = Source.getRepository(SalePaymentRequest);
const existing = await invoiceRepo.findOne({
where: { serviceTicketId: payload.serviceTicketId, notes: marker },
});
- if (existing) return existing;
+ if (existing) {
+ const live = await requestRepo.findOne({
+ where: [
+ { invoiceId: existing.id, status: 'PENDING' },
+ { invoiceId: existing.id, status: 'APPROVED' },
+ ],
+ order: { createdAt: 'DESC' },
+ });
+ if (live) return { invoice: existing, paymentRequestId: live.id };
+
+ const retry = await createSalePaymentRequest({
+ invoiceId: existing.id,
+ branchId: payload.branchId,
+ userId: payload.collectedBy,
+ amount,
+ paymentMode: payload.paymentMode || 'CASH',
+ paymentDate: new Date(),
+ cashAccountId: payload.accountId,
+ remarks: payload.remarks || `Service Visit Charge — Ticket ${payload.ticketNumber ?? ''}`,
+ paymentContext: 'SERVICE_VISIT_CHARGE',
+ chequeNumber: payload.chequeNumber,
+ chequeBankName: payload.chequeBankName,
+ chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ });
+ return { invoice: existing, paymentRequestId: retry.id };
+ }
const invoiceNumber = await this.invoiceRepo.generateInvoiceNumber();
const label = `Service Visit Charge — Ticket ${payload.ticketNumber || payload.serviceTicketId}`;
@@ -4816,28 +5006,38 @@ export class BillingService {
delete (invoiceItem as { invoice?: unknown }).invoice;
savedInvoice.items = [invoiceItem];
- await this.recordPayment(
- savedInvoice.id,
- {
- paymentMode: payload.paymentMode || 'CASH',
- accountId: payload.accountId,
- amount,
- remarks: payload.remarks || `${label} — collected on-site by technician`,
- bypassStatusCheck: true,
- },
- payload.collectedBy,
- );
+ // The money is NOT posted here. It goes to the Accounts queue as a pending request,
+ // exactly like a sale collection, and only reaches the cashbook when Accounts approve
+ // it. Before this, whoever clicked "Collect" moved real cash on their own authority —
+ // the one control the rest of this system applies to every other collection was the
+ // only one missing from the visit charge.
+ const request = await createSalePaymentRequest({
+ invoiceId: savedInvoice.id,
+ branchId: payload.branchId,
+ userId: payload.collectedBy,
+ amount,
+ paymentMode: payload.paymentMode || 'CASH',
+ paymentDate: new Date(),
+ cashAccountId: payload.accountId,
+ remarks: payload.remarks || label,
+ paymentContext: 'SERVICE_VISIT_CHARGE',
+ chequeNumber: payload.chequeNumber,
+ chequeBankName: payload.chequeBankName,
+ chequeDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ chequeDueDate: payload.chequeDate ? new Date(payload.chequeDate) : undefined,
+ });
- const withPayment = await this.invoiceRepo.findById(savedInvoice.id);
- return withPayment || savedInvoice;
+ return { invoice: savedInvoice, paymentRequestId: request.id };
}
/**
* Creates the lump-sum invoice for an AMC service contract at signing time, and — if an
- * initial payment was collected on the spot — records it against that same invoice via the
- * normal payment path so status (INVOICED/PARTIAL via ledger/PAID) and cashbook posting stay
- * consistent with every other payment in the system. Later installments reuse this invoiceId
- * through the existing /invoices/:id/payments or /payments/record endpoints.
+ * initial payment was collected on the spot — raises it as a PENDING SalePaymentRequest
+ * against that same invoice, exactly like every other collection in the system (sale,
+ * rent, visit charge). It does NOT touch the cashbook or InvoiceLedger here: whoever took
+ * the money at signing has no authority to post it themselves, so the contract stays
+ * INVOICED/unpaid until Finance approves the request. Later installments reuse this same
+ * invoiceId through the existing sale-payments (recordSalePayment) path.
*/
async createContractInvoice(payload: {
customerId: string;
@@ -4853,6 +5053,7 @@ export class BillingService {
paymentDate?: string;
referenceNumber?: string;
remarks?: string;
+ cashAccountId?: string;
};
}): Promise {
const invoiceNumber = await this.invoiceRepo.generateInvoiceNumber();
@@ -4895,20 +5096,20 @@ export class BillingService {
savedInvoice.items = [invoiceItem];
if (payload.initialPayment && payload.initialPayment.amount > 0) {
- await this.recordPayment(
- savedInvoice.id,
- {
- paymentMode: payload.initialPayment.paymentMode,
- amount: payload.initialPayment.amount,
- transactionDate: payload.initialPayment.paymentDate,
- referenceNumber: payload.initialPayment.referenceNumber,
- remarks: payload.initialPayment.remarks || 'Initial payment at contract signing',
- bypassStatusCheck: true,
- },
- payload.createdBy,
- );
- const withPayment = await this.invoiceRepo.findById(savedInvoice.id);
- if (withPayment) return withPayment;
+ await createSalePaymentRequest({
+ invoiceId: savedInvoice.id,
+ branchId: payload.branchId,
+ userId: payload.createdBy,
+ amount: payload.initialPayment.amount,
+ paymentMode: payload.initialPayment.paymentMode,
+ paymentDate: payload.initialPayment.paymentDate
+ ? new Date(payload.initialPayment.paymentDate)
+ : new Date(),
+ referenceNumber: payload.initialPayment.referenceNumber,
+ remarks: payload.initialPayment.remarks || 'Initial payment at contract signing',
+ cashAccountId: payload.initialPayment.cashAccountId,
+ paymentContext: 'SERVICE_CONTRACT_SIGNING',
+ });
}
return savedInvoice;
diff --git a/backend/billing_service/src/services/paymentService.ts b/backend/billing_service/src/services/paymentService.ts
index 0af69e78..1de0c2c7 100644
--- a/backend/billing_service/src/services/paymentService.ts
+++ b/backend/billing_service/src/services/paymentService.ts
@@ -6,6 +6,7 @@ import { AppError } from '../errors/appError';
import { BillingService } from './billingService';
import { loadExchangeRates, convertAmt } from '../utils/accountsShared';
import { r2SignedGetUrl } from '../utils/r2Url';
+import { SalePaymentRequest } from '../entities/salePaymentRequestEntity';
/**
* Normalized payment record served to the frontend. Merges the current
@@ -187,6 +188,16 @@ export class PaymentService {
const totalAmount = Number(invoice.totalAmount || 0);
const pendingBalance = Math.max(0, Math.round((totalAmount - totalPaid) * 100) / 100);
+ // Money collected but sitting in the Accounts Receipts queue, not yet approved —
+ // never counted in totalPaid/pendingBalance above (those only reflect posted
+ // PaymentTransactions). Surfaced separately so a contract/invoice can show
+ // "N payment(s) awaiting Finance approval" instead of looking simply unpaid.
+ const pendingRequests = await Source.getRepository(SalePaymentRequest).find({
+ where: { invoiceId, status: 'PENDING' },
+ });
+ const pendingApprovalCount = pendingRequests.length;
+ const pendingApprovalAmount = pendingRequests.reduce((sum, r) => sum + Number(r.amount), 0);
+
return {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber,
@@ -196,6 +207,8 @@ export class PaymentService {
totalAmount,
totalPaid,
pendingBalance,
+ pendingApprovalCount,
+ pendingApprovalAmount,
payments,
status: invoice.status,
currencyWarnings,
diff --git a/backend/billing_service/src/utils/serviceTicketSync.ts b/backend/billing_service/src/utils/serviceTicketSync.ts
new file mode 100644
index 00000000..cdcb3dd4
--- /dev/null
+++ b/backend/billing_service/src/utils/serviceTicketSync.ts
@@ -0,0 +1,61 @@
+import { logger } from '../config/logger';
+
+/**
+ * Tells the inventory service what Accounts decided about a service visit charge.
+ *
+ * The ticket lives in ven_inv but the approval happens here, so without this the desk
+ * would show "awaiting approval" forever after Accounts had already signed it off. The
+ * call is deliberately best-effort and never throws: the money has already been posted
+ * inside a committed transaction by the time we get here, and failing the approval
+ * response because a status mirror could not be delivered would leave Accounts believing
+ * their approval did not happen while the cash had in fact moved.
+ *
+ * The ticket's own status is a mirror, not the source of truth — the SalePaymentRequest
+ * is. A reconcile on read (see the service page's ticket loader) closes any gap left by a
+ * delivery that failed here.
+ */
+export async function syncVisitChargeDecision(params: {
+ serviceTicketId: string;
+ status: 'COLLECTED' | 'REJECTED';
+ paymentRequestId: string;
+ rejectionReason?: string | null;
+}): Promise {
+ try {
+ const { sign } = await import('jsonwebtoken');
+ const token = sign(
+ { userId: 'billing_service', role: 'ADMIN' },
+ process.env.ACCESS_SECRET as string,
+ { expiresIn: '1m' },
+ );
+ const base = process.env.INVENTORY_SERVICE_URL || 'http://localhost:3003';
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 6000);
+ const res = await fetch(
+ `${base}/service/tickets/${params.serviceTicketId}/visit-charge-decision`,
+ {
+ method: 'PATCH',
+ signal: controller.signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'x-internal-service': 'billing',
+ },
+ body: JSON.stringify({
+ status: params.status,
+ paymentRequestId: params.paymentRequestId,
+ rejectionReason: params.rejectionReason ?? null,
+ }),
+ },
+ );
+ clearTimeout(timer);
+ if (!res.ok) {
+ logger.warn(
+ `Visit charge decision not mirrored to ticket ${params.serviceTicketId}: HTTP ${res.status}`,
+ );
+ }
+ } catch (err) {
+ logger.warn(
+ `Visit charge decision not mirrored to ticket ${params.serviceTicketId}: ${(err as Error).message}`,
+ );
+ }
+}
diff --git a/backend/employee_service/package.json b/backend/employee_service/package.json
index e90b5b36..4a111300 100644
--- a/backend/employee_service/package.json
+++ b/backend/employee_service/package.json
@@ -23,6 +23,7 @@
"@types/multer": "^2.0.0",
"@types/multer-s3": "^3.0.3",
"@types/node": "^25.0.2",
+ "@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.4",
"@types/ua-parser-js": "^0.7.39",
"nodemon": "^3.1.11",
@@ -44,6 +45,7 @@
"morgan": "^1.10.1",
"multer": "^2.0.2",
"multer-s3": "^3.0.1",
+ "node-cron": "^4.4.1",
"nodemailer": "^7.0.11",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
diff --git a/backend/employee_service/src/app.ts b/backend/employee_service/src/app.ts
index d7942991..3d0a3751 100644
--- a/backend/employee_service/src/app.ts
+++ b/backend/employee_service/src/app.ts
@@ -14,6 +14,7 @@ import cookieParser from 'cookie-parser';
import { getRabbitChannel } from './config/rabbitmq';
import { startWorker } from './workers/emailWorker';
import { startBranchConsumer } from './events/consumers/branchConsumer';
+import { startDeviceCleanupCron } from './services/deviceCleanupCron';
import { httpLogger } from './middleware/httplogger';
import healthRouter from './routes/health';
import { logger } from './config/logger';
@@ -97,6 +98,7 @@ const startServer = async () => {
// Start the automatic email and office notification systems
await startWorker();
await startBranchConsumer();
+ startDeviceCleanupCron();
const PORT = process.env.EMPLOYEE_PORT || process.env.PORT || 3002;
diff --git a/backend/employee_service/src/config/cookieOptions.ts b/backend/employee_service/src/config/cookieOptions.ts
index 0901d766..da7f4483 100644
--- a/backend/employee_service/src/config/cookieOptions.ts
+++ b/backend/employee_service/src/config/cookieOptions.ts
@@ -49,3 +49,23 @@ export const clearCookieOptions: CookieOptions = {
sameSite: 'lax',
path: '/',
};
+
+export const TRUSTED_DEVICE_COOKIE_NAME = 'xc_device_token';
+
+/** 1 day — staff verify with OTP once each morning, plain password the rest of the day. */
+export const TRUSTED_DEVICE_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000;
+
+export const trustedDeviceCookieOptions: CookieOptions = {
+ httpOnly: true,
+ secure: isSecure,
+ sameSite: 'lax',
+ maxAge: TRUSTED_DEVICE_COOKIE_MAX_AGE,
+ path: '/',
+};
+
+export const clearTrustedDeviceCookieOptions: CookieOptions = {
+ httpOnly: true,
+ secure: isSecure,
+ sameSite: 'lax',
+ path: '/',
+};
diff --git a/backend/employee_service/src/config/dataSource.ts b/backend/employee_service/src/config/dataSource.ts
index 7c8428db..66129468 100644
--- a/backend/employee_service/src/config/dataSource.ts
+++ b/backend/employee_service/src/config/dataSource.ts
@@ -16,6 +16,8 @@ import { EmployeeDocument } from '../entities/employeeDocumentEntity';
import { logger } from './logger';
import { seedAdmin } from '../utils/seedAdmin';
+const EMPLOYEE_DB_POOL_MAX = Number(process.env.EMPLOYEE_DB_POOL_MAX) || 10;
+
export const Source = new DataSource({
type: 'postgres',
url: process.env.EMPLOYEE_DATABASE_URL,
@@ -34,9 +36,14 @@ export const Source = new DataSource({
LateMark,
EmployeeDocument,
],
- poolSize: 1,
+ // A pool of 1 serializes every concurrent DB-backed request onto a single
+ // connection — invoice-list enrichment alone fans out one employee lookup
+ // per unique creator per page, and those all queue up behind this one slot.
+ // billing_service hit the identical failure (see its dataSource.ts) before
+ // this was made configurable there; mirroring that fix here.
+ poolSize: EMPLOYEE_DB_POOL_MAX,
extra: {
- max: 1,
+ max: EMPLOYEE_DB_POOL_MAX,
min: 0,
connectionTimeoutMillis: 5000,
keepAlive: true,
@@ -298,6 +305,27 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise
try {
const { user } = await authService.login(req.body);
+ // Local E2E test escape hatch only — never set SKIP_LOGIN_OTP in a deployed
+ // environment. Lets the Jest E2E suite log in without polling a mailbox for OTPs.
+ if (process.env.SKIP_LOGIN_OTP === 'true') {
+ const { accessToken } = await issueTokens(user, req, res);
+ logger.info('login successfull (OTP skipped: SKIP_LOGIN_OTP=true)');
+ return res.json({
+ message: 'Login successfull',
+ accessToken,
+ data: user,
+ success: true,
+ });
+ }
+
+ // Trusted device — same browser verified with OTP within the last 24h.
+ // Credentials are still checked above; this only skips the second factor.
+ const deviceToken = req.cookies?.[TRUSTED_DEVICE_COOKIE_NAME];
+ if (deviceToken) {
+ const trusted = await trustedDeviceRepo.findValid(hashDeviceToken(deviceToken), user.id);
+ if (trusted) {
+ await trustedDeviceRepo.touch(hashDeviceToken(deviceToken));
+ const { accessToken } = await issueTokens(user, req, res);
+ logger.info(`login successfull (trusted device, OTP skipped) for ${user.email}`);
+ return res.json({
+ message: 'Login successfull',
+ accessToken,
+ data: user,
+ success: true,
+ });
+ }
+ }
+
otpService
.sendOtp(user.email, OtpPurpose.LOGIN)
.then(() => logger.info(`OTP sent successfully to ${user.email} for login`))
@@ -63,6 +101,19 @@ export const loginVerify = async (req: Request, res: Response, next: NextFunctio
const user = await authService.findUserByEmail(email);
const { accessToken } = await issueTokens(user, req, res);
+
+ const rawDeviceToken = generateDeviceToken();
+ const deviceName = (req.headers['user-agent'] as string) || 'Unknown Device';
+ const expiresAt = new Date(Date.now() + TRUSTED_DEVICE_COOKIE_MAX_AGE);
+ await trustedDeviceRepo.create(
+ user.id,
+ hashDeviceToken(rawDeviceToken),
+ deviceName,
+ req.ip,
+ expiresAt,
+ );
+ res.cookie(TRUSTED_DEVICE_COOKIE_NAME, rawDeviceToken, trustedDeviceCookieOptions);
+
logger.info('login successfull');
return res.json({
@@ -173,7 +224,7 @@ export const forgotPassword = async (req: Request, res: Response, next: NextFunc
const user = await authService.findUserByEmail(email);
if (!user) {
return res.json({
- message: 'If account exists, magic link sent',
+ message: 'If account exists, an OTP has been sent to your email',
success: true,
});
}
@@ -181,7 +232,7 @@ export const forgotPassword = async (req: Request, res: Response, next: NextFunc
await otpService.sendOtp(email, OtpPurpose.FORGOT_PASSWORD);
return res.json({
- message: 'If account exists, magic link sent',
+ message: 'If account exists, an OTP has been sent to your email',
success: true,
});
} catch (err: unknown) {
@@ -212,6 +263,13 @@ export const resetPassword = async (req: Request, res: Response, next: NextFunct
await authService.logoutOtherDevices(user.id, currentRefreshToken);
}
+ // Verifying an OTP here confirms email access for a password reset, not
+ // a login — it must not leave (or count as re-establishing) a trusted
+ // device. Revoke every trusted device so the very next login asks for
+ // OTP again, regardless of any still-valid cookie from before the reset.
+ await trustedDeviceRepo.deleteAllForUser(user.id);
+ res.clearCookie(TRUSTED_DEVICE_COOKIE_NAME, clearTrustedDeviceCookieOptions);
+
return res.json({
message: 'Password reset successfully',
success: true,
@@ -352,6 +410,58 @@ export const logoutSession = async (req: Request, res: Response, next: NextFunct
}
};
+/**
+ * List trusted devices:
+ * Show every browser/device that can currently log in with just
+ * email + password (no OTP) for this account.
+ */
+export const getTrustedDevices = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const userId = req.user.userId;
+ const devices = await trustedDeviceRepo.listByUser(userId);
+ return res.json({ data: devices, success: true });
+ } catch (err: unknown) {
+ const error = err as AuthError;
+ next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500));
+ }
+};
+
+/**
+ * Revoke one trusted device:
+ * That browser will need OTP again on its next login.
+ */
+export const revokeTrustedDevice = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const userId = req.user.userId;
+ const deviceId = req.params.deviceId as string;
+ const deleted = await trustedDeviceRepo.deleteById(deviceId, userId);
+ if (!deleted) {
+ return next(new AppError('Trusted device not found', 404));
+ }
+ return res.json({ message: 'Trusted device revoked', success: true });
+ } catch (err: unknown) {
+ const error = err as AuthError;
+ next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500));
+ }
+};
+
+/**
+ * Revoke every trusted device:
+ * Useful if a password may be compromised — every browser, including
+ * this one, will need OTP again on its next login.
+ */
+export const revokeAllTrustedDevices = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const userId = req.user.userId;
+ await trustedDeviceRepo.deleteAllForUser(userId);
+ res.clearCookie(TRUSTED_DEVICE_COOKIE_NAME, clearTrustedDeviceCookieOptions);
+ return res.json({ message: 'All trusted devices revoked', success: true });
+ } catch (err: unknown) {
+ const error = err as AuthError;
+ next(new AppError(error.message || 'Internal Server Error', error.statusCode || 500));
+ }
+};
+
/**
* Get My Profile:
* Retrieve the basic details (name, role, branch) for the staff member
diff --git a/backend/employee_service/src/repositories/trustedDeviceRepository.ts b/backend/employee_service/src/repositories/trustedDeviceRepository.ts
new file mode 100644
index 00000000..72340696
--- /dev/null
+++ b/backend/employee_service/src/repositories/trustedDeviceRepository.ts
@@ -0,0 +1,70 @@
+import { Source } from '../config/dataSource';
+
+export interface TrustedDeviceRow {
+ id: string;
+ user_id: string;
+ user_type: string;
+ device_token_hash: string;
+ device_name: string | null;
+ ip_address: string | null;
+ last_used_at: string;
+ expires_at: string;
+ created_at: string;
+}
+
+export class TrustedDeviceRepository {
+ async findValid(hash: string, userId: string): Promise {
+ const rows = await Source.query(
+ `SELECT * FROM trusted_devices WHERE device_token_hash = $1 AND user_id = $2 AND expires_at > NOW()`,
+ [hash, userId],
+ );
+ return rows[0] || null;
+ }
+
+ async touch(hash: string) {
+ await Source.query(
+ `UPDATE trusted_devices SET last_used_at = NOW() WHERE device_token_hash = $1`,
+ [hash],
+ );
+ }
+
+ async create(
+ userId: string,
+ hash: string,
+ deviceName: string,
+ ipAddress: string | undefined,
+ expiresAt: Date,
+ ) {
+ await Source.query(
+ `INSERT INTO trusted_devices (user_id, user_type, device_token_hash, device_name, ip_address, expires_at)
+ VALUES ($1, 'EMPLOYEE', $2, $3, $4, $5)
+ ON CONFLICT (device_token_hash) DO NOTHING`,
+ [userId, hash, deviceName, ipAddress || null, expiresAt],
+ );
+ }
+
+ async listByUser(userId: string): Promise {
+ return Source.query(
+ `SELECT id, device_name, ip_address, last_used_at, expires_at, created_at
+ FROM trusted_devices WHERE user_id = $1 ORDER BY last_used_at DESC`,
+ [userId],
+ );
+ }
+
+ /** Ownership-scoped so one user can't revoke another's device by guessing an id. */
+ async deleteById(id: string, userId: string): Promise {
+ const rows = await Source.query(
+ `DELETE FROM trusted_devices WHERE id = $1 AND user_id = $2 RETURNING id`,
+ [id, userId],
+ );
+ return rows.length;
+ }
+
+ async deleteAllForUser(userId: string) {
+ await Source.query(`DELETE FROM trusted_devices WHERE user_id = $1`, [userId]);
+ }
+
+ async deleteExpired() {
+ await Source.query(`DELETE FROM trusted_devices WHERE expires_at < NOW()`);
+ }
+}
diff --git a/backend/employee_service/src/routes/authRouter.ts b/backend/employee_service/src/routes/authRouter.ts
index 23c5cfe5..ca0c1deb 100644
--- a/backend/employee_service/src/routes/authRouter.ts
+++ b/backend/employee_service/src/routes/authRouter.ts
@@ -13,6 +13,9 @@ import {
getSessions,
logoutSession,
getMe,
+ getTrustedDevices,
+ revokeTrustedDevice,
+ revokeAllTrustedDevices,
} from '../controllers/authController';
import { authMiddleware } from '../middleware/authMiddleware';
@@ -98,4 +101,21 @@ authRouter.get('/sessions', authMiddleware, getSessions);
*/
authRouter.post('/sessions/logout', authMiddleware, logoutSession);
+// --- 5. Trusted Devices (skip-OTP) ---
+
+/**
+ * List browsers/devices that can currently log in without OTP.
+ */
+authRouter.get('/trusted-devices', authMiddleware, getTrustedDevices);
+
+/**
+ * Revoke one trusted device — it will need OTP again next login.
+ */
+authRouter.delete('/trusted-devices/:deviceId', authMiddleware, revokeTrustedDevice);
+
+/**
+ * Revoke every trusted device for this account (e.g. password compromised).
+ */
+authRouter.post('/revoke-trusted-devices', authMiddleware, revokeAllTrustedDevices);
+
export default authRouter;
diff --git a/backend/employee_service/src/services/deviceCleanupCron.ts b/backend/employee_service/src/services/deviceCleanupCron.ts
new file mode 100644
index 00000000..d9e6aa44
--- /dev/null
+++ b/backend/employee_service/src/services/deviceCleanupCron.ts
@@ -0,0 +1,19 @@
+import cron from 'node-cron';
+import { Source } from '../config/dataSource';
+import { logger } from '../config/logger';
+
+/**
+ * Daily sweep of expired trusted-device rows. The 1-day cookie itself already
+ * stops the browser from sending an expired token, but the DB row would
+ * otherwise accumulate forever.
+ */
+export function startDeviceCleanupCron() {
+ cron.schedule('0 2 * * *', async () => {
+ try {
+ await Source.query(`DELETE FROM trusted_devices WHERE expires_at < NOW()`);
+ logger.info('Cleaned up expired trusted devices');
+ } catch (err) {
+ logger.error('Failed to clean up expired trusted devices:', err);
+ }
+ });
+}
diff --git a/backend/employee_service/src/utils/deviceToken.ts b/backend/employee_service/src/utils/deviceToken.ts
new file mode 100644
index 00000000..13508d9a
--- /dev/null
+++ b/backend/employee_service/src/utils/deviceToken.ts
@@ -0,0 +1,13 @@
+import crypto from 'crypto';
+
+/**
+ * The raw token lives in the browser cookie; only its hash is ever stored or
+ * looked up server-side, so a DB read alone can't reconstruct a valid cookie.
+ */
+export function hashDeviceToken(token: string): string {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+export function generateDeviceToken(): string {
+ return crypto.randomBytes(32).toString('hex');
+}
diff --git a/backend/employee_service/src/utils/mailer.ts b/backend/employee_service/src/utils/mailer.ts
index ec216fbd..9cf3032e 100644
--- a/backend/employee_service/src/utils/mailer.ts
+++ b/backend/employee_service/src/utils/mailer.ts
@@ -1,13 +1,44 @@
import nodemailer from 'nodemailer';
import * as XLSX from 'xlsx';
-export const mailer = nodemailer.createTransport({
- service: 'gmail',
- auth: {
- user: process.env.MAIL_USER,
- pass: process.env.MAIL_PASS,
- },
-});
+/**
+ * Local dev hammers Gmail's send rate limit (every OTP is a real email).
+ * Outside production, send through Ethereal instead — a throwaway SMTP
+ * sandbox that never delivers anything real; view caught mail at
+ * https://ethereal.email using ETHEREAL_USER/ETHEREAL_PASS.
+ *
+ * If those aren't set (not opted in yet), fall back to the same Gmail
+ * transport used in production rather than a silent no-op transport —
+ * this repo's Gmail creds are already live via the shell environment
+ * (not a tracked .env), so login/OTP/magic-link must keep working out of
+ * the box until someone deliberately configures Ethereal.
+ */
+const createTransporter = () => {
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ process.env.ETHEREAL_USER &&
+ process.env.ETHEREAL_PASS
+ ) {
+ return nodemailer.createTransport({
+ host: 'smtp.ethereal.email',
+ port: 587,
+ auth: {
+ user: process.env.ETHEREAL_USER,
+ pass: process.env.ETHEREAL_PASS,
+ },
+ });
+ }
+
+ return nodemailer.createTransport({
+ service: 'gmail',
+ auth: {
+ user: process.env.MAIL_USER,
+ pass: process.env.MAIL_PASS,
+ },
+ });
+};
+
+export const mailer = createTransporter();
export async function sendEmployeeWelcomeMail(to: string, password: string) {
await mailer.sendMail({
diff --git a/backend/ven_inv_service/src/config/db.ts b/backend/ven_inv_service/src/config/db.ts
index 8d788d7c..4db46386 100644
--- a/backend/ven_inv_service/src/config/db.ts
+++ b/backend/ven_inv_service/src/config/db.ts
@@ -751,12 +751,51 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise 1;
+ `);
logger.info('Guaranteed service_estimate_revisions table exists.');
+ // Repair tickets stranded by the QUOTED/FINANCE_APPROVED mismatch described in
+ // serviceController.financeApproved. Their estimate row already says
+ // FINANCE_APPROVED — only the ticket was left on QUOTED, which made the customer
+ // share refuse them forever. Scoped to tickets whose own estimate proves the
+ // approval happened, so nothing is promoted that Finance never approved.
+ await Source.query(`
+ UPDATE service_tickets t
+ SET status = 'FINANCE_APPROVED'
+ WHERE t.status = 'QUOTED'
+ AND EXISTS (
+ SELECT 1 FROM service_estimates e
+ WHERE e."ticketId" = t.id AND e.status = 'FINANCE_APPROVED'
+ );
+ `);
+
await Source.query(`
CREATE TABLE IF NOT EXISTS service_estimate_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -837,12 +876,27 @@ export const connectWithRetry = async (initialDelayMs = 2000): Promise {
try {
@@ -596,6 +619,53 @@ export class ServiceController {
);
}
+ /**
+ * Single source of truth for "what's covered on this ticket" — replaces the
+ * 3 previously-divergent per-site variants (a real contract's coverage always
+ * wins; otherwise RENT/LEASE_CPC/FSMA are fully free, WARRANTY/
+ * LEASE_UNDER_WARRANTY/SMA are free except toner, everything else pays for
+ * everything). Used regardless of "track" — coverage and finance-routing are
+ * separate concerns now.
+ */
+ private async resolveItemCoverage(ticket: {
+ contractReferenceId?: string | null;
+ serviceContext: ServiceContext;
+ }): Promise {
+ const contractCoverage = await this.getTicketContractCoverage(ticket.contractReferenceId);
+ if (contractCoverage) return contractCoverage;
+ if (
+ ticket.serviceContext === ServiceContext.RENT ||
+ ticket.serviceContext === ServiceContext.LEASE_CPC ||
+ ticket.serviceContext === ServiceContext.FSMA
+ ) {
+ return { ...FULL_COVERAGE };
+ }
+ if (
+ ticket.serviceContext === ServiceContext.WARRANTY ||
+ ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY ||
+ ticket.serviceContext === ServiceContext.SMA
+ ) {
+ return { ...WARRANTY_COVERAGE };
+ }
+ return { ...NO_COVERAGE };
+ }
+
+ /**
+ * An item is free when its category is covered by contract/warranty rules,
+ * OR when staff explicitly flagged it free (a one-way override — this can
+ * only ever add extra free items on top of coverage, never take coverage
+ * away from an item the rules say should be covered).
+ */
+ private resolveItemIsFree(
+ coverage: ContractCoverage,
+ item: { partCategory?: string | null; partName?: string | null; isFree?: boolean },
+ ): boolean {
+ return (
+ coverageAllowsItem(coverage, { partCategory: item.partCategory, partName: item.partName }) ||
+ !!item.isFree
+ );
+ }
+
/** Maps sparePartId → part_category for coverage checks over item lists. */
private async getPartCategories(
sparePartIds: Array,
@@ -917,13 +987,42 @@ export class ServiceController {
}
const ticketRepo = Source.getRepository(ServiceTicket);
+
+ // A machine can only be under one active repair at a time — block a new
+ // ticket until the open one is COMPLETED, CANCELLED, or rejected by
+ // customer/finance. Matched by productId when the machine is on file,
+ // else by serial number (e.g. "Other Machine" walk-ins with no product record).
+ const terminalStatuses = [
+ ServiceTicketStatus.COMPLETED,
+ ServiceTicketStatus.CANCELLED,
+ ServiceTicketStatus.CUSTOMER_REJECTED,
+ ServiceTicketStatus.FINANCE_REJECTED,
+ ];
+ if (productId || serialNumber) {
+ const openTicket = await ticketRepo.findOne({
+ where: productId
+ ? { productId, status: Not(In(terminalStatuses)) }
+ : { serialNumber, status: Not(In(terminalStatuses)) },
+ });
+ if (openTicket) {
+ throw new AppError(
+ `This machine already has an open service ticket (${openTicket.ticketNumber}). Complete, cancel, or reject it before opening a new one.`,
+ 400,
+ );
+ }
+ }
+
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const count = await ticketRepo.count();
const ticketNumber = `ST-${year}${month}-${String(count + 1).padStart(4, '0')}`;
- const status = track === 'A' ? ServiceTicketStatus.FREE_SERVICE : ServiceTicketStatus.OPEN;
+ // Every ticket starts OPEN and goes through the same diagnose →
+ // finance-approval → customer-approval → complete pipeline, regardless
+ // of whether it turns out to be free/covered — `track` only affects the
+ // WARRANTY_ONSITE jobType lock above, not the ticket lifecycle.
+ const status = ServiceTicketStatus.OPEN;
const branchId = req.user.branchId || req.body.branchId;
if (!branchId) throw new Error('Branch ID is required');
@@ -971,7 +1070,7 @@ export class ServiceController {
await this.logActivity(
ticket.id,
'CREATION',
- `Ticket ${ticket.ticketNumber} created under context ${ticket.serviceContext} (${track === 'A' ? 'Free Track A' : 'Chargeable Track B'})`,
+ `Ticket ${ticket.ticketNumber} created under context ${ticket.serviceContext}${track === 'A' ? ' (covered/warranty-onsite)' : ''}`,
req.user.userId,
);
@@ -1252,6 +1351,7 @@ Xerocare Technical Services`;
visitChargeCollected,
visitChargePaymentMode,
visitChargeAccountId,
+ visitChargeChequeNumber,
transportChargeAmount,
discountAmount,
technicianNoteToFinance,
@@ -1300,8 +1400,6 @@ Xerocare Technical Services`;
}
}
- const isFreeContext = ticket.track === 'A';
-
// Visit + transportation are free whenever the machine is covered by a
// service contract (AMC/SMA/FSMA — all include travel), by warranty, or is
// a company-owned RENT machine. Only uncovered machines pay these charges.
@@ -1328,15 +1426,16 @@ Xerocare Technical Services`;
// transactional, so a validation error thrown after ticket/estimate
// writes have already landed would leave a half-committed state.
if (
- !isFreeContext &&
visitChargeMethod === 'SEPARATE' &&
effectiveVisitCharge > 0 &&
visitChargeCollected &&
!ticket.visitChargeCollected &&
- (!visitChargePaymentMode || (visitChargePaymentMode !== 'CHEQUE' && !visitChargeAccountId))
+ (!visitChargePaymentMode ||
+ (visitChargePaymentMode !== 'CHEQUE' && !visitChargeAccountId) ||
+ (visitChargePaymentMode === 'CHEQUE' && !visitChargeChequeNumber))
) {
throw new AppError(
- 'Payment mode (and account, unless paying by cheque) are required to post the visit charge.',
+ 'Payment mode is required to post the visit charge — plus an account, or a cheque number when paying by cheque.',
400,
);
}
@@ -1356,15 +1455,14 @@ Xerocare Technical Services`;
ticket.meterReadingAtService = meterReading || 0;
}
- if (isFreeContext) {
- ticket.status = ServiceTicketStatus.FREE_SERVICE;
- } else {
- ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL;
- ticket.visitChargeAmount = effectiveVisitCharge;
- ticket.visitChargeMethod = visitChargeMethod || null;
- ticket.transportChargeAmount = effectiveTransportCharge;
- ticket.discountAmount = Number(discountAmount) || 0;
- }
+ // Every context now goes through the same finance-approval pipeline —
+ // covered items still price at 0, finance just reviews/marks them FOC
+ // instead of the ticket silently bypassing finance entirely.
+ ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL;
+ ticket.visitChargeAmount = effectiveVisitCharge;
+ ticket.visitChargeMethod = visitChargeMethod || null;
+ ticket.transportChargeAmount = effectiveTransportCharge;
+ ticket.discountAmount = Number(discountAmount) || 0;
await ticketRepo.save(ticket);
@@ -1390,16 +1488,12 @@ Xerocare Technical Services`;
const itemRepo = Source.getRepository(ServiceTicketItem);
const ticketItems: ServiceTicketItem[] = [];
+ let hasCustomItem = false;
- // Under a contract, "free" is per item category (e.g. SMA covers spare
- // parts but charges toner). Warranty mirrors SMA (toner chargeable);
- // RENT + no-contract track A stay fully free.
- const contractCoverage =
- (await this.getTicketContractCoverage(ticket.contractReferenceId)) ??
- (ticket.serviceContext === ServiceContext.WARRANTY ||
- ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY
- ? { ...WARRANTY_COVERAGE }
- : null);
+ // Single source of truth for what's covered — real contract rules win,
+ // else RENT/LEASE_CPC/FSMA are fully free, WARRANTY/LEASE_UNDER_WARRANTY/
+ // SMA are free except toner, everything else pays for everything.
+ const contractCoverage = await this.resolveItemCoverage(ticket);
if (items && Array.isArray(items)) {
const sparePartRepo = Source.getRepository(SparePart);
@@ -1411,6 +1505,7 @@ Xerocare Technical Services`;
let partCategory: string | null = null;
let partBrand = itemData.customPartBrand || null;
let mpn = itemData.mpn || null;
+ let unitCost = 0;
if (itemData.itemSource === ServiceItemSource.SPARE_PART && itemData.sparePartId) {
const part = await sparePartRepo.findOne({
@@ -1424,6 +1519,7 @@ Xerocare Technical Services`;
partCategory = part.part_category || null;
partBrand = part.brand || null;
mpn = part.mpn || null;
+ unitCost = Number(part.purchase_price) || 0;
// Check stock warnings
if (part.quantity <= 5) {
@@ -1436,13 +1532,18 @@ Xerocare Technical Services`;
});
}
}
+ } else if (itemData.itemSource === ServiceItemSource.CUSTOM) {
+ unitCost = Number(itemData.customPartCost) || 0;
+ hasCustomItem = true;
}
- const coveredByContract = contractCoverage
- ? coverageAllowsItem(contractCoverage, { partCategory, partName })
- : true;
- const isItemFree = isFreeContext ? coveredByContract : !!itemData.isFree;
+ const isItemFree = this.resolveItemIsFree(contractCoverage, {
+ partCategory,
+ partName,
+ isFree: itemData.isFree,
+ });
const finalPrice = isItemFree ? 0 : unitPrice;
+ const quantity = itemData.quantity || 1;
const serviceItem = itemRepo.create({
ticketId: ticket.id,
@@ -1456,85 +1557,37 @@ Xerocare Technical Services`;
partBrand,
mpn,
partName,
- quantity: itemData.quantity || 1,
+ quantity,
unitPrice: finalPrice,
- totalPrice: finalPrice * (itemData.quantity || 1),
+ totalPrice: finalPrice * quantity,
isFree: isItemFree,
+ unitCost,
+ totalCost: unitCost * quantity,
});
ticketItems.push(serviceItem);
-
- // Reserve Parts Immediately for Free Workflows
- if (
- isFreeContext &&
- itemData.itemSource === ServiceItemSource.SPARE_PART &&
- itemData.sparePartId
- ) {
- await this.reserveSparePart(ticket.id, itemData.sparePartId, itemData.quantity || 1);
- }
}
}
ticket.items = ticketItems;
await ticketRepo.save(ticket);
- // For Track A, automatically generate/send FOC Estimate to billing service.
- // Items excluded by the contract (e.g. toner under SMA) keep their price.
- if (isFreeContext) {
- const billingItems = ticketItems.map((item) => ({
- itemSource: item.itemSource,
- productId: item.sparePartId || null,
- sparePartId: item.sparePartId || null,
- partName: item.partName,
- sku: item.sku,
- quantity: item.quantity,
- unitPrice: Number(item.unitPrice) || 0,
- totalPrice: Number(item.totalPrice) || 0,
- isFree: item.isFree,
- }));
- try {
- const token = sign(
- { userId: 'ven_inv_service', role: 'ADMIN' },
- ACCESS_SECRET as string,
- {
- expiresIn: '1m',
- },
- );
-
- const response = await axios.post(
- `${BILLING_SERVICE_URL}/invoices/service-quotation`,
- {
- customerId: ticket.customerId,
- branchId: ticket.branchId,
- createdBy: req.user?.userId || 'SYSTEM',
- serviceTicketId: ticket.id,
- items: billingItems,
- saleType: 'SERVICE',
- status: 'APPROVED',
- },
- {
- headers: {
- Authorization: `Bearer ${token}`,
- },
- },
- );
- if (response.data?.success) {
- ticket.linkedInvoiceId = response.data.data.id;
- ticket.estimateSentToFinance = true;
- await ticketRepo.save(ticket);
- }
- } catch (billingErr) {
- logger.error('Failed to post FOC estimate to billing service:', billingErr);
- }
-
- await this.logActivity(
- ticket.id,
- 'ESTIMATE_RECORDED',
- `FOC estimate automatically created and sent to billing service.`,
- req.user?.userId,
- );
+ if (hasCustomItem) {
+ await this.notifyBranchManagerAndAdmins(ticket.branchId, {
+ title: 'Spare Part Procurement Needed',
+ message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`,
+ type: 'ACTION_REQUIRED',
+ referenceId: ticket.id,
+ referenceType: 'CUSTOM_PART_REQUEST',
+ });
}
- // If Track B workflow with parts or labor, automatically generate a WAITING_FINANCE_APPROVAL Estimate
+ // Parts are reserved once the customer (or finance, for FOC-only
+ // estimates) actually approves the estimate — not here — so a rejected
+ // estimate never leaves stock wrongly held.
+
+ // Automatically generate a WAITING_FINANCE_APPROVAL Estimate whenever
+ // there's anything to estimate. Covered items are already priced at 0
+ // above — finance reviews and approves the FOC estimate like any other.
const inputLabour = Number(labourCost) || 0;
const finalLabourCost =
ticket.serviceContext === ServiceContext.AMC ||
@@ -1548,30 +1601,25 @@ Xerocare Technical Services`;
: inputLabour;
if (
- !isFreeContext &&
- ((ticketItems && ticketItems.length > 0) ||
- finalLabourCost > 0 ||
- effectiveVisitCharge > 0 ||
- effectiveTransportCharge > 0)
+ (ticketItems && ticketItems.length > 0) ||
+ finalLabourCost > 0 ||
+ effectiveVisitCharge > 0 ||
+ effectiveTransportCharge > 0
) {
- // Discount applies to the whole estimate (parts + labour + transport +
- // visit charge when added to the estimate) — cap it there.
- const billablePartsTotal = ticketItems.reduce(
- (sum, it) => sum + (Number(it.totalPrice) || 0),
- 0,
- );
- const estimateGrandTotal =
- billablePartsTotal +
- finalLabourCost +
- effectiveTransportCharge +
- (visitChargeMethod === 'ADDED_TO_ESTIMATE' ? effectiveVisitCharge : 0);
- if (Number(discountAmount || 0) > estimateGrandTotal) {
+ // Discount is a discount on the labour/service charge only — it
+ // cannot exceed the labour cost, regardless of parts/transport/visit charge.
+ if (Number(discountAmount || 0) > finalLabourCost) {
throw new AppError(
- `Discount of QAR ${discountAmount} exceeds the total estimate amount of QAR ${estimateGrandTotal}.`,
+ `Discount of QAR ${discountAmount} exceeds the labour cost of QAR ${finalLabourCost}.`,
400,
);
}
+ const billablePartsTotal = ticketItems.reduce(
+ (sum, it) => sum + (Number(it.totalPrice) || 0),
+ 0,
+ );
+
const estimateRepo = Source.getRepository(ServiceEstimate);
const estItemRepo = Source.getRepository(ServiceEstimateItem);
@@ -1608,6 +1656,8 @@ Xerocare Technical Services`;
totalPrice: ticketItem.totalPrice,
isFree: ticketItem.isFree,
isApproved: true,
+ unitCost: ticketItem.unitCost,
+ totalCost: ticketItem.totalCost,
});
estItemsToSave.push(estItem);
}
@@ -1700,45 +1750,26 @@ Xerocare Technical Services`;
// billing immediately so it shows up in the cashbook / day book and earnings
// without waiting for the estimate to be paid.
if (
- !isFreeContext &&
visitChargeMethod === 'SEPARATE' &&
effectiveVisitCharge > 0 &&
visitChargeCollected &&
- !ticket.visitChargeCollected
+ // Covers the case the desk already took it: pending approval counts as taken, so
+ // the technician cannot collect the same charge a second time.
+ !this.isVisitChargeSettledOrPending(ticket)
) {
try {
- const token = sign(
- { userId: 'ven_inv_service', role: 'ADMIN' },
- ACCESS_SECRET as string,
- {
- expiresIn: '1m',
- },
- );
- await axios.post(
- `${BILLING_SERVICE_URL}/invoices/service-visit-charge`,
- {
- serviceTicketId: ticket.id,
- ticketNumber: ticket.ticketNumber,
- customerId: ticket.customerId,
- branchId: ticket.branchId,
- amount: effectiveVisitCharge,
- collectedBy: req.user?.userId || 'SYSTEM',
- paymentMode: visitChargePaymentMode,
- accountId: visitChargeAccountId,
- },
- { headers: { Authorization: `Bearer ${token}` } },
- );
- ticket.visitChargeCollected = true;
- ticket.visitChargeCollectedAt = new Date();
- await ticketRepo.save(ticket);
- await this.logActivity(
- ticket.id,
- 'VISIT_CHARGE_COLLECTED',
- `Visit charge of ${effectiveVisitCharge} collected in cash on-site and posted to accounts.`,
- req.user?.userId,
- );
+ await this.requestVisitChargeApproval(ticket, {
+ paymentMode: visitChargePaymentMode,
+ accountId: visitChargeAccountId,
+ chequeNumber: visitChargeChequeNumber,
+ userId: req.user?.userId,
+ userName: await this.resolveCollectorName(req),
+ userRole: req.user?.employeeJob || req.user?.role,
+ remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected on-site by technician at diagnosis`,
+ activityNote: `Visit charge of ${effectiveVisitCharge} collected on-site by the technician — sent to Accounts for approval.`,
+ });
} catch (err) {
- logger.error('Failed to post on-site visit charge receipt to billing:', err);
+ logger.error('Failed to raise on-site visit charge approval request:', err);
}
}
@@ -1809,6 +1840,8 @@ Xerocare Technical Services`;
let calculatedTotal = Number(estimate.labourCost);
const savedItems: ServiceEstimateItem[] = [];
+ let hasCustomItem = false;
+ const coverage = await this.resolveItemCoverage(ticket);
if (items && Array.isArray(items)) {
const sparePartRepo = Source.getRepository(SparePart);
@@ -1817,6 +1850,7 @@ Xerocare Technical Services`;
let sku = it.sku || '';
let basePrice = Number(it.unitPrice) || 0;
let partCategory: string | null = null;
+ let unitCost = 0;
if (it.sparePartId) {
const part = await sparePartRepo.findOne({ where: { id: String(it.sparePartId) } });
@@ -1827,28 +1861,22 @@ Xerocare Technical Services`;
if (basePrice === 0) {
basePrice = Number(part.base_price) || 0;
}
+ unitCost = Number(part.purchase_price) || 0;
}
+ } else {
+ unitCost = Number(it.customPartCost) || 0;
+ hasCustomItem = true;
}
- let isItemFree = !!it.isFree;
- if (
- ticket.serviceContext === ServiceContext.RENT ||
- ticket.serviceContext === ServiceContext.LEASE_CPC ||
- ticket.serviceContext === ServiceContext.FSMA
- ) {
- isItemFree = true;
- } else if (
- ticket.serviceContext === ServiceContext.WARRANTY ||
- ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY
- ) {
- // Warranty mirrors SMA: parts free, toner chargeable
- isItemFree = coverageAllowsItem(WARRANTY_COVERAGE, { partCategory, partName });
- } else if (ticket.serviceContext === ServiceContext.AMC) {
- isItemFree = false;
- }
+ const isItemFree = this.resolveItemIsFree(coverage, {
+ partCategory,
+ partName,
+ isFree: it.isFree,
+ });
const finalPrice = isItemFree ? 0 : basePrice;
- const itemTotal = finalPrice * (it.quantity || 1);
+ const quantity = it.quantity || 1;
+ const itemTotal = finalPrice * quantity;
calculatedTotal += itemTotal;
const estItem = estItemRepo.create({
@@ -1859,16 +1887,28 @@ Xerocare Technical Services`;
sparePartId: it.sparePartId || null,
sku,
partName,
- quantity: it.quantity || 1,
+ quantity,
unitPrice: finalPrice,
totalPrice: itemTotal,
isFree: isItemFree,
isApproved: true,
+ unitCost,
+ totalCost: unitCost * quantity,
});
savedItems.push(estItem);
}
}
+ if (hasCustomItem) {
+ await this.notifyBranchManagerAndAdmins(ticket.branchId, {
+ title: 'Spare Part Procurement Needed',
+ message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`,
+ type: 'ACTION_REQUIRED',
+ referenceId: ticket.id,
+ referenceType: 'CUSTOM_PART_REQUEST',
+ });
+ }
+
estimate.totalCost = calculatedTotal;
estimate.items = savedItems;
await estimateRepo.save(estimate);
@@ -2464,6 +2504,8 @@ Xerocare Technical Services`;
ticket,
{ collectVisitCharge, paymentMode, accountId },
req.user?.userId,
+ await this.resolveCollectorName(req),
+ req.user?.employeeJob || req.user?.role,
);
} catch (err) {
logger.error('Failed to collect visit charge at estimate rejection:', err);
@@ -2543,21 +2585,15 @@ Xerocare Technical Services`;
await revisionRepo.save(revision);
const sparePartRepo = Source.getRepository(SparePart);
- // Contract decides what stays free per item category: SMA/FSMA cover
- // parts, AMC does not, and toner is only covered under FSMA. Warranty
- // mirrors SMA (toner chargeable).
- const revisionCoverage =
- (await this.getTicketContractCoverage(ticket.contractReferenceId)) ??
- (ticket.serviceContext === ServiceContext.WARRANTY ||
- ticket.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY
- ? { ...WARRANTY_COVERAGE }
- : null);
+ const revisionCoverage = await this.resolveItemCoverage(ticket);
+ let hasCustomItem = false;
if (items && Array.isArray(items)) {
for (const it of items) {
let partName = it.partName || '';
let sku = it.sku || '';
let basePrice = Number(it.unitPrice) || 0;
let partCategory: string | null = null;
+ let unitCost = 0;
if (it.sparePartId) {
const part = await sparePartRepo.findOne({ where: { id: String(it.sparePartId) } });
@@ -2568,16 +2604,21 @@ Xerocare Technical Services`;
if (basePrice === 0) {
basePrice = Number(part.base_price) || 0;
}
+ unitCost = Number(part.purchase_price) || 0;
}
+ } else {
+ unitCost = Number(it.customPartCost) || 0;
+ hasCustomItem = true;
}
- const isItemFree = revisionCoverage
- ? coverageAllowsItem(revisionCoverage, { partCategory, partName })
- : ticket.track === 'A'
- ? true
- : !!it.isFree;
+ const isItemFree = this.resolveItemIsFree(revisionCoverage, {
+ partCategory,
+ partName,
+ isFree: it.isFree,
+ });
const finalPrice = isItemFree ? 0 : basePrice;
- const itemTotal = finalPrice * (it.quantity || 1);
+ const quantity = it.quantity || 1;
+ const itemTotal = finalPrice * quantity;
revisionTotal += itemTotal;
const revItem = estItemRepo.create({
@@ -2588,16 +2629,28 @@ Xerocare Technical Services`;
sparePartId: it.sparePartId || null,
sku,
partName,
- quantity: it.quantity || 1,
+ quantity,
unitPrice: finalPrice,
totalPrice: itemTotal,
isFree: isItemFree,
isApproved: false,
+ unitCost,
+ totalCost: unitCost * quantity,
});
revisionItems.push(revItem);
}
}
+ if (hasCustomItem) {
+ await this.notifyBranchManagerAndAdmins(ticket.branchId, {
+ title: 'Spare Part Procurement Needed',
+ message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`,
+ type: 'ACTION_REQUIRED',
+ referenceId: ticket.id,
+ referenceType: 'CUSTOM_PART_REQUEST',
+ });
+ }
+
revision.totalCost = revisionTotal;
revision.items = revisionItems;
await revisionRepo.save(revision);
@@ -2866,6 +2919,14 @@ Xerocare Technical Services`;
technicianRemarks,
customerSignature,
technicianSignature,
+ // Payment the technician took on the spot. Optional — a customer who pays later
+ // still closes the job, the invoice just stays outstanding for Accounts to chase.
+ collectedAmount,
+ paymentMode,
+ paymentAccountId,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
} = req.body;
const id = req.params.id as string;
@@ -2944,6 +3005,49 @@ Xerocare Technical Services`;
});
await reportRepo.save(report);
+ // Payment taken at the door, if any. Best-effort: the job IS finished, and failing
+ // the completion because the collection could not be raised would leave the
+ // technician unable to close a ticket for work that is demonstrably done. The
+ // failure is logged and the invoice simply stays outstanding.
+ if (paymentMode && Number(collectedAmount) > 0 && ticket.serviceQuotationId) {
+ try {
+ const payToken = sign(
+ { userId: 'ven_inv_service', role: 'ADMIN' },
+ ACCESS_SECRET as string,
+ { expiresIn: '1m' },
+ );
+ const payRes = await axios.post(
+ `${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/service-completion-payment`,
+ {
+ amount: Number(collectedAmount),
+ paymentMode,
+ accountId: paymentAccountId,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
+ branchId: ticket.branchId,
+ collectedBy: req.user?.userId,
+ remarks: `Service completion payment — Ticket ${ticket.ticketNumber} — collected by technician`,
+ },
+ { headers: { Authorization: `Bearer ${payToken}` } },
+ );
+ await this.logActivity(
+ ticket.id,
+ 'COMPLETION_PAYMENT_COLLECTED',
+ `Technician collected ${collectedAmount} by ${paymentMode} — sent to Accounts for approval (${payRes.data?.data?.requestNo ?? 'request raised'}).`,
+ req.user?.userId,
+ );
+ } catch (err) {
+ logger.error('Failed to raise completion payment request:', err);
+ await this.logActivity(
+ ticket.id,
+ 'COMPLETION_PAYMENT_FAILED',
+ `Could not record the ${collectedAmount} collected at completion. The invoice remains outstanding — record it from Accounts.`,
+ req.user?.userId,
+ );
+ }
+ }
+
// Consume Reserved Parts
await this.consumeReservations(ticket.id);
@@ -2955,89 +3059,125 @@ Xerocare Technical Services`;
relations: ['items'],
});
- // What the customer was actually billed for parts/consumables (used for the
- // customer/UI-facing Machine History stat) — distinct from purchaseCost below,
- // which is the internal cost basis used only for ServicePartUsageLog/margin tracking.
- let totalPartsBilled = 0;
+ // The customer didn't (fully) pay on the spot — remind this branch's
+ // Finance team so they can chase it down, rather than it silently
+ // sitting outstanding until someone happens to notice. Fires once, at
+ // completion; staff can collect it later via collectCompletionPayment.
+ const collectedAtCompletion =
+ paymentMode && Number(collectedAmount) > 0 ? Number(collectedAmount) : 0;
+ const remainingDue = Math.max(
+ 0,
+ (estimate ? Number(estimate.totalCost) : 0) - collectedAtCompletion,
+ );
+ if (remainingDue > 0 && ticket.serviceQuotationId) {
+ try {
+ const financeEmployees = await getFinanceEmployeesByBranch(ticket.branchId);
+ for (const financeEmployeeId of financeEmployees) {
+ await NotificationPublisher.publishInAppRequest({
+ recipientId: financeEmployeeId,
+ title: 'Uncollected Service Payment',
+ message: `Ticket ${ticket.ticketNumber} completed with ${remainingDue.toFixed(2)} not yet collected from the customer — follow up with the technician.`,
+ type: 'WARNING',
+ referenceId: ticket.id,
+ referenceType: 'SERVICE',
+ });
+ }
+ } catch (err) {
+ logger.warn('Failed to notify finance of uncollected completion balance:', err);
+ }
+ }
+
+ // Real internal spend, regardless of what the customer was billed — this
+ // is what MachineServiceHistory.totalPartsSpend reflects (see below).
+ let totalPartsCostInternal = 0;
const itemsToInspect = estimate ? estimate.items : ticket.items;
const usageLogRepo = Source.getRepository(ServicePartUsageLog);
for (const item of itemsToInspect) {
- totalPartsBilled +=
- Number(item.totalPrice) || (Number(item.unitPrice) || 0) * (item.quantity || 1);
-
- let purchaseCost = 0;
- if (item.sparePartId) {
+ // Prefer the cost captured at diagnosis/estimate time (covers CUSTOM
+ // items, which have no catalog price to re-derive here) — only fall
+ // back to a fresh SparePart lookup for items that predate that column.
+ let purchaseCost = item.unitCost != null ? Number(item.unitCost) || 0 : 0;
+ let partNameForLog = item.partName || '';
+ let skuForLog = item.sku || '';
+ let isConsumableItem = this.isConsumable(partNameForLog, skuForLog);
+
+ if (item.unitCost == null && item.sparePartId) {
const partDetails = await sparePartRepo.findOne({
where: { id: String(item.sparePartId) },
});
if (partDetails) {
purchaseCost = Number(partDetails.purchase_price) || 0;
- const itemCost = purchaseCost * item.quantity;
-
- // Yield page calculation for consumables if replaced
- let yieldPages = 0;
- if (this.isConsumable(partDetails.part_name, partDetails.sku)) {
- const yieldRepo = Source.getRepository(ConsumableYieldHistory);
- const activeYield = await yieldRepo.findOne({
- where: {
- serialNumber: ticket.serialNumber,
- tonerSku: item.sku || '',
- replacedMeterReading: IsNull(),
- },
- order: { installedDate: 'DESC' },
- });
-
- if (activeYield) {
- activeYield.replacedDate = new Date();
- activeYield.replacedMeterReading = meterReading || 0;
- activeYield.yieldPages = Math.max(
- 0,
- (meterReading || 0) - activeYield.installedMeterReading,
- );
- await yieldRepo.save(activeYield);
- yieldPages = activeYield.yieldPages;
- }
+ partNameForLog = partDetails.part_name;
+ skuForLog = partDetails.sku;
+ isConsumableItem = this.isConsumable(partDetails.part_name, partDetails.sku);
+ }
+ }
- const newYield = yieldRepo.create({
- productId: ticket.productId || undefined,
- serialNumber: ticket.serialNumber,
- tonerSku: item.sku || '',
- installedDate: new Date(),
- installedMeterReading: meterReading || 0,
- ticketId: ticket.id,
- });
- await yieldRepo.save(newYield);
- }
+ const itemCost = purchaseCost * item.quantity;
+ totalPartsCostInternal += itemCost;
+
+ // Yield page calculation for consumables if replaced
+ let yieldPages = 0;
+ if (isConsumableItem) {
+ const yieldRepo = Source.getRepository(ConsumableYieldHistory);
+ const activeYield = await yieldRepo.findOne({
+ where: {
+ serialNumber: ticket.serialNumber,
+ tonerSku: skuForLog || '',
+ replacedMeterReading: IsNull(),
+ },
+ order: { installedDate: 'DESC' },
+ });
- // Save Part Usage Log — productId is NOT NULL here (it's a per-machine
- // usage record), so this is skipped entirely for a ticket with no
- // matched Product (e.g. a serial number not yet in the catalog).
- // Previously this passed '' as a fallback, which isn't a valid uuid
- // and crashed the whole completion request with a 500 — after the
- // ticket's status had already been committed as COMPLETED, silently
- // skipping the rest of the function including the Manager notification.
- if (ticket.productId) {
- const usageLog = usageLogRepo.create({
- ticketId: ticket.id,
- productId: ticket.productId,
- sparePartId: item.sparePartId,
- sku: item.sku || '',
- partName: item.partName || '',
- quantityUsed: item.quantity,
- unitCost: purchaseCost,
- totalCost: itemCost,
- replacedAt: new Date(),
- calculatedYield: yieldPages || null,
- isFree: item.isFree,
- isConsumable: this.isConsumable(partDetails.part_name, partDetails.sku),
- meterReadingAtReplacement: meterReading || null,
- linkedInvoiceId: ticket.linkedInvoiceId || null,
- });
- await usageLogRepo.save(usageLog);
- }
+ if (activeYield) {
+ activeYield.replacedDate = new Date();
+ activeYield.replacedMeterReading = meterReading || 0;
+ activeYield.yieldPages = Math.max(
+ 0,
+ (meterReading || 0) - activeYield.installedMeterReading,
+ );
+ await yieldRepo.save(activeYield);
+ yieldPages = activeYield.yieldPages;
}
+
+ const newYield = yieldRepo.create({
+ productId: ticket.productId || undefined,
+ serialNumber: ticket.serialNumber,
+ tonerSku: skuForLog || '',
+ installedDate: new Date(),
+ installedMeterReading: meterReading || 0,
+ ticketId: ticket.id,
+ });
+ await yieldRepo.save(newYield);
+ }
+
+ // Save Part Usage Log — productId is NOT NULL here (it's a per-machine
+ // usage record), so this is skipped entirely for a ticket with no
+ // matched Product (e.g. a serial number not yet in the catalog).
+ // Previously this passed '' as a fallback, which isn't a valid uuid
+ // and crashed the whole completion request with a 500 — after the
+ // ticket's status had already been committed as COMPLETED, silently
+ // skipping the rest of the function including the Manager notification.
+ if (ticket.productId) {
+ const usageLog = usageLogRepo.create({
+ ticketId: ticket.id,
+ productId: ticket.productId,
+ sparePartId: item.sparePartId || null,
+ sku: skuForLog,
+ partName: partNameForLog,
+ quantityUsed: item.quantity,
+ unitCost: purchaseCost,
+ totalCost: itemCost,
+ replacedAt: new Date(),
+ calculatedYield: yieldPages || null,
+ isFree: item.isFree,
+ isConsumable: isConsumableItem,
+ meterReadingAtReplacement: meterReading || null,
+ linkedInvoiceId: ticket.linkedInvoiceId || null,
+ });
+ await usageLogRepo.save(usageLog);
}
}
@@ -3051,10 +3191,14 @@ Xerocare Technical Services`;
// come back first; the fallback create() path then crashed on `''` not
// being a valid uuid. Skipping entirely is correct: there's no real
// machine to attribute lifetime history to.
- if (ticket.productId) {
+ //
+ // Keyed by serialNumber (not productId) so external machines — never
+ // purchased from us, no matching Product row — still get a lifetime
+ // spend/service-count record; productId is attached when we have it.
+ if (ticket.serialNumber) {
const historyRepo = Source.getRepository(MachineServiceHistory);
let historyRecord = await historyRepo.findOne({
- where: { productId: ticket.productId },
+ where: { serialNumber: ticket.serialNumber },
});
let nextScheduledMaintenanceDate: Date | null = null;
@@ -3073,21 +3217,27 @@ Xerocare Technical Services`;
}
historyRecord.lastServiceDate = new Date();
historyRecord.nextScheduledMaintenanceDate = nextScheduledMaintenanceDate;
- historyRecord.totalPartsSpend = Number(historyRecord.totalPartsSpend) + totalPartsBilled;
+ historyRecord.totalPartsSpend =
+ Number(historyRecord.totalPartsSpend) + totalPartsCostInternal;
historyRecord.totalLabourSpend = Number(historyRecord.totalLabourSpend) + labourCost;
historyRecord.totalLifetimeCost =
Number(historyRecord.totalPartsSpend) + Number(historyRecord.totalLabourSpend);
+ // Backfill productId if this machine was matched to the catalog
+ // after its history row was first created as an external machine.
+ if (!historyRecord.productId && ticket.productId) {
+ historyRecord.productId = ticket.productId;
+ }
} else {
historyRecord = historyRepo.create({
- productId: ticket.productId,
+ productId: ticket.productId || null,
serialNumber: ticket.serialNumber,
totalServiceVisits: 1,
totalPreventativeVisits: ticket.ticketType === 'PREVENTATIVE_MAINTENANCE' ? 1 : 0,
lastServiceDate: new Date(),
nextScheduledMaintenanceDate,
- totalPartsSpend: totalPartsBilled,
+ totalPartsSpend: totalPartsCostInternal,
totalLabourSpend: labourCost,
- totalLifetimeCost: totalPartsBilled + labourCost,
+ totalLifetimeCost: totalPartsCostInternal + labourCost,
});
}
await historyRepo.save(historyRecord);
@@ -3338,6 +3488,7 @@ Xerocare Technical Services`;
currentColorA4?: number;
currentColorA3?: number;
warrantyInfo?: unknown;
+ currentMeterReading?: number;
}>;
}>
>,
@@ -3362,6 +3513,7 @@ Xerocare Technical Services`;
for (const inv of invoices) {
for (const alloc of inv.productAllocations || []) {
const product = alloc.productId ? productById.get(alloc.productId) : undefined;
+ alloc.currentMeterReading = product?.meter_reading ?? undefined;
if (billType === 'RENT') {
alloc.warrantyInfo = { isUnderWarranty: true, fullCoverage: true };
@@ -3458,31 +3610,12 @@ Xerocare Technical Services`;
);
let activeContract = null;
- let coverage: ContractCoverage = { ...NO_COVERAGE };
-
if (details.contractReferenceId) {
activeContract = await Source.getRepository(ServiceContract).findOne({
where: { id: details.contractReferenceId as string },
});
- if (activeContract) {
- // Coverage rules are fixed per contract type; normalize legacy rows
- coverage = normalizeCoverage(
- activeContract.coverageRules || coverageForContractType(activeContract.contractType),
- );
- }
- } else if (
- details.serviceContext === ServiceContext.RENT ||
- details.serviceContext === ServiceContext.LEASE_CPC
- ) {
- // RENT and CPC leases are full-service — spare parts AND toner free.
- coverage = { ...FULL_COVERAGE };
- } else if (
- details.serviceContext === ServiceContext.WARRANTY ||
- details.serviceContext === ServiceContext.LEASE_UNDER_WARRANTY
- ) {
- // Warranty mirrors SMA: toner chargeable
- coverage = { ...WARRANTY_COVERAGE };
}
+ const coverage = await this.resolveItemCoverage(details);
res.status(200).json({
success: true,
@@ -3559,6 +3692,90 @@ Xerocare Technical Services`;
}
};
+ /**
+ * GET /service/machines/:serialNumber/analytics
+ *
+ * Consolidated per-machine lifetime stats — keyed by serialNumber alone so
+ * it works for RENT/LEASE/SALE machines AND external machines never
+ * purchased from us (no matching Product row). Additive alongside
+ * getMachineLifetimeCost, which existing callers keep using; new frontend
+ * surfaces should call this one instead.
+ */
+ getMachineAnalytics = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const serialNumber = String(req.params.serialNumber);
+ const ticketRepo = Source.getRepository(ServiceTicket);
+ const tickets = await ticketRepo.find({
+ where: { serialNumber, status: ServiceTicketStatus.COMPLETED },
+ order: { completedAt: 'ASC' },
+ });
+
+ const usageLogRepo = Source.getRepository(ServicePartUsageLog);
+ const estimateRepo = Source.getRepository(ServiceEstimate);
+
+ let lifetimePartsCost = 0;
+ let lifetimeLabourCost = 0;
+ const ticketBreakdown = [];
+
+ for (const t of tickets) {
+ const usageLogs = await usageLogRepo.find({ where: { ticketId: t.id } });
+ const partsCostInternal = usageLogs.reduce((sum, l) => sum + (Number(l.totalCost) || 0), 0);
+
+ const estimate = await estimateRepo.findOne({
+ where: { ticketId: t.id, status: ServiceEstimateStatus.CUSTOMER_APPROVED },
+ });
+ const labourCost = estimate ? Number(estimate.labourCost) || 0 : 0;
+
+ lifetimePartsCost += partsCostInternal;
+ lifetimeLabourCost += labourCost;
+
+ ticketBreakdown.push({
+ ticketId: t.id,
+ ticketNumber: t.ticketNumber,
+ date: t.completedAt,
+ serviceContext: t.serviceContext,
+ partsUsed: usageLogs.map((l) => ({
+ partName: l.partName,
+ sku: l.sku,
+ quantity: l.quantityUsed,
+ unitCost: Number(l.unitCost) || 0,
+ totalCost: Number(l.totalCost) || 0,
+ isConsumable: l.isConsumable,
+ })),
+ partsCostInternal,
+ labourCost,
+ totalSpend: partsCostInternal + labourCost,
+ });
+ }
+
+ const yieldRepo = Source.getRepository(ConsumableYieldHistory);
+ const yieldHistory = await yieldRepo.find({
+ where: { serialNumber },
+ order: { installedDate: 'DESC' },
+ });
+ const replacementHistory = yieldHistory.filter((y) => y.replacedDate != null);
+
+ res.status(200).json({
+ success: true,
+ data: {
+ serialNumber,
+ serviceVisitCount: tickets.length,
+ tickets: ticketBreakdown,
+ toner: {
+ totalTonerReplacements: replacementHistory.length,
+ replacementHistory,
+ yieldHistory,
+ },
+ lifetimePartsCost,
+ lifetimeLabourCost,
+ lifetimeSpend: lifetimePartsCost + lifetimeLabourCost,
+ },
+ });
+ } catch (error) {
+ next(error);
+ }
+ };
+
/**
* GET /service/finance/dashboard
*/
@@ -3837,35 +4054,15 @@ Xerocare Technical Services`;
);
}
- // RENT and CPC leases cover everything; warranty mirrors SMA (toner
- // chargeable); contract contexts cover per item category (SMA/AMC charge
- // toner, AMC charges parts too). Labour is free under all three contract types.
- const baseFreeContext =
- ticket.serviceContext === ServiceContext.RENT ||
- ticket.serviceContext === ServiceContext.LEASE_CPC;
- const warrantyContext = [
- ServiceContext.WARRANTY,
- ServiceContext.LEASE_UNDER_WARRANTY,
- ].includes(ticket.serviceContext);
- const quoteCoverage: ContractCoverage = baseFreeContext
- ? { ...FULL_COVERAGE }
- : warrantyContext
- ? { ...WARRANTY_COVERAGE }
- : ((await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? {
- ...NO_COVERAGE,
- });
+ const quoteCoverage = await this.resolveItemCoverage(ticket);
const partCategories = await this.getPartCategories(ticket.items.map((it) => it.sparePartId));
- const hasContractContext = baseFreeContext || warrantyContext || !!ticket.contractReferenceId;
-
const items = ticket.items.map((it) => {
- const itemCovered = hasContractContext
- ? coverageAllowsItem(quoteCoverage, {
- partCategory: it.sparePartId ? partCategories.get(it.sparePartId) : null,
- partName: it.partName,
- })
- : false;
- const isFree = itemCovered || !!it.isFree;
+ const isFree = this.resolveItemIsFree(quoteCoverage, {
+ partCategory: it.sparePartId ? partCategories.get(it.sparePartId) : null,
+ partName: it.partName,
+ isFree: it.isFree,
+ });
return {
description: it.partName,
quantity: it.quantity,
@@ -3874,7 +4071,7 @@ Xerocare Technical Services`;
};
});
- const labourFree = hasContractContext && quoteCoverage.labour;
+ const labourFree = quoteCoverage.labour;
items.push({
description: 'Labor Cost / Service Charge',
quantity: 1,
@@ -3882,11 +4079,10 @@ Xerocare Technical Services`;
isFree: labourFree,
});
- const effectiveVisitCharge =
- hasContractContext && quoteCoverage.travel ? 0 : Number(visitChargeAmount) || 0;
-
- const allFree = items.every((it) => it.isFree) && effectiveVisitCharge === 0;
+ const effectiveVisitCharge = quoteCoverage.travel ? 0 : Number(visitChargeAmount) || 0;
+ // Every ticket goes to finance for review — covered/free items already
+ // price at 0 above, finance just approves the FOC estimate like any other.
const billingPayload = {
customerId: ticket.customerId,
branchId: ticket.branchId,
@@ -3898,7 +4094,7 @@ Xerocare Technical Services`;
discountAmount: Number(discountAmount) || 0,
technicianNoteToFinance: technicianNoteToFinance || null,
saleType: 'PRODUCT_SALE',
- status: allFree ? 'CUSTOMER_ACCEPTED' : 'WAITING_FINANCE_APPROVAL',
+ status: 'WAITING_FINANCE_APPROVAL',
};
const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, {
@@ -3916,12 +4112,7 @@ Xerocare Technical Services`;
);
const quotation = quoteRes.data.data;
- if (allFree) {
- ticket.status = ServiceTicketStatus.CUSTOMER_APPROVED;
- } else {
- ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL;
- }
-
+ ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL;
ticket.serviceQuotationId = quotation.id;
ticket.visitChargeAmount = effectiveVisitCharge;
ticket.visitChargeMethod = visitChargeMethod || null;
@@ -3975,7 +4166,21 @@ Xerocare Technical Services`;
const ticket = await ticketRepo.findOne({ where: { id: String(id) } });
if (!ticket) throw new Error('Ticket not found');
- ticket.status = ServiceTicketStatus.QUOTED;
+ // FINANCE_APPROVED, not QUOTED.
+ //
+ // This is the cross-service half of Finance approving an estimate — billing calls it
+ // the moment the approval commits — and it used to leave the ticket on QUOTED while
+ // its sibling approveEstimateFinance (the in-service path) set FINANCE_APPROVED. The
+ // send-to-customer guard reads the TICKET and demands FINANCE_APPROVED, so an
+ // estimate approved through Accounts could never be sent: the customer share refused
+ // with "The estimate must be approved by Finance" about an estimate Finance had
+ // just approved. The estimate row said FINANCE_APPROVED; only the ticket disagreed.
+ //
+ // A re-estimate lands on the _2 state, mirroring approveRevisionFinance.
+ ticket.status =
+ ticket.status === ServiceTicketStatus.WAITING_FINANCE_APPROVAL_2
+ ? ServiceTicketStatus.FINANCE_APPROVED_2
+ : ServiceTicketStatus.FINANCE_APPROVED;
await ticketRepo.save(ticket);
const estimateRepo = Source.getRepository(ServiceEstimate);
@@ -4273,8 +4478,28 @@ Xerocare Technical Services`;
);
}
- if (invoiceFetched && !approvalTravelCovered) {
- // Timely approval — waive the labour line on the billing estimate.
+ /**
+ * Labour is waived because the customer ALREADY PAID a visit charge.
+ *
+ * That is the deal the system itself offers them, verbatim: "Paying the visit charge
+ * also covers the labour cost of the repair for one month." The waiver is the other
+ * half of a transaction — it is not a discount handed out for approving on time.
+ *
+ * It used to fire on every chargeable approval regardless, including tickets where
+ * the visit charge was ADDED_TO_ESTIMATE and so had never been collected. A job
+ * quoted at 550 became a 150 bill the instant the customer signed: the company
+ * forgave 400 of labour in exchange for a visit charge it never received, and the
+ * figure the customer had signed for was one the system never intended to charge.
+ *
+ * Now it reciprocates only a payment that actually happened. When the visit charge is
+ * folded into the estimate the customer is paying it as part of this same bill, so
+ * there is no prepayment to return the favour for and the quote stands as quoted.
+ */
+ const visitChargeWasPaid =
+ ticket.visitChargeCollected === true || ticket.visitChargeStatus === 'COLLECTED';
+
+ if (invoiceFetched && !approvalTravelCovered && visitChargeWasPaid) {
+ // Timely approval on a ticket whose visit charge is already paid — waive labour.
try {
await axios.post(
`${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/waive-labour`,
@@ -4284,7 +4509,7 @@ Xerocare Technical Services`;
await this.logActivity(
ticket.id,
'LABOUR_WAIVED',
- 'Customer approved within estimate validity — labour cost waived (covered by the up-front visit/estimate charge).',
+ 'Labour waived — covered by the visit charge the customer already paid.',
actorId,
);
} catch (err) {
@@ -4469,7 +4694,7 @@ Xerocare Technical Services`;
ticket.serviceContext === ServiceContext.CHARGEABLE &&
Number(ticket.visitChargeAmount) > 0 &&
ticket.visitChargeMethod === 'ADDED_TO_ESTIMATE' &&
- !ticket.visitChargeCollected
+ !this.isVisitChargeSettledOrPending(ticket)
);
}
@@ -4490,12 +4715,49 @@ Xerocare Technical Services`;
ticket: ServiceTicket,
body: { collectVisitCharge?: boolean; paymentMode?: string; accountId?: string },
userId?: string,
+ collectorName?: string,
+ collectorRole?: string,
): Promise {
if (!body.collectVisitCharge || !this.isVisitChargeCollectionEligible(ticket)) return;
+ await this.requestVisitChargeApproval(ticket, {
+ paymentMode: body.paymentMode,
+ accountId: body.accountId,
+ userId,
+ userName: collectorName,
+ userRole: collectorRole,
+ remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected at estimate rejection`,
+ activityNote: `Visit charge of ${ticket.visitChargeAmount} collected at estimate rejection — sent to Accounts for approval.`,
+ });
+ }
+
+ /**
+ * Sends a collected visit charge to Accounts for approval and marks the ticket pending.
+ *
+ * Every collection point funnels through here — the desk's up-front button, the
+ * technician's on-site collection at diagnosis, and collection at estimate rejection —
+ * so all three obey the same rule: the person takes the money, Accounts decide whether
+ * it posts. Previously each one called billing directly and the cash landed in the
+ * cashbook on the collector's own authority.
+ */
+ private async requestVisitChargeApproval(
+ ticket: ServiceTicket,
+ opts: {
+ paymentMode?: string;
+ accountId?: string;
+ chequeNumber?: string;
+ chequeBankName?: string;
+ chequeDate?: string;
+ userId?: string;
+ userName?: string;
+ userRole?: string;
+ remarks?: string;
+ activityNote?: string;
+ },
+ ): Promise {
const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, {
expiresIn: '1m',
});
- await axios.post(
+ const response = await axios.post(
`${BILLING_SERVICE_URL}/invoices/service-visit-charge`,
{
serviceTicketId: ticket.id,
@@ -4503,24 +4765,133 @@ Xerocare Technical Services`;
customerId: ticket.customerId,
branchId: ticket.branchId,
amount: Number(ticket.visitChargeAmount),
- collectedBy: userId || 'SYSTEM',
- paymentMode: body.paymentMode,
- accountId: body.accountId,
- remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected at estimate rejection`,
+ collectedBy: opts.userId || 'SYSTEM',
+ collectedByName: opts.userName || null,
+ collectedByRole: opts.userRole || null,
+ paymentMode: opts.paymentMode,
+ accountId: opts.accountId,
+ chequeNumber: opts.chequeNumber,
+ chequeBankName: opts.chequeBankName,
+ chequeDate: opts.chequeDate,
+ remarks: opts.remarks,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
- ticket.visitChargeCollected = true;
- ticket.visitChargeCollectedAt = new Date();
+
+ ticket.visitChargeStatus = 'PENDING_APPROVAL';
+ ticket.visitChargeRequestId = response.data?.data?.paymentRequestId ?? null;
+ ticket.visitChargeCollectedBy = opts.userId || null;
+ ticket.visitChargeCollectedByName = opts.userName || null;
+ ticket.visitChargeCollectedByRole = opts.userRole || null;
+ ticket.visitChargeRejectionReason = null;
+ // visitChargeCollected stays FALSE until Accounts approve — see the entity note.
+ if (!ticket.visitChargeMethod) ticket.visitChargeMethod = 'SEPARATE';
await Source.getRepository(ServiceTicket).save(ticket);
+
await this.logActivity(
ticket.id,
- 'VISIT_CHARGE_COLLECTED',
- `Visit charge of ${ticket.visitChargeAmount} collected at estimate rejection and posted to accounts.`,
- userId,
+ 'VISIT_CHARGE_REQUESTED',
+ opts.activityNote ||
+ `Visit charge of ${ticket.visitChargeAmount} collected by ${opts.userName || 'staff'} — sent to Accounts for approval.`,
+ opts.userId,
+ );
+ }
+
+ /**
+ * The collector's display name for the Accounts queue and the ticket.
+ *
+ * Falls back to the email, then the role, then "Staff" — Accounts must always see a
+ * person against money that has been taken, and a blank name in that column is the
+ * thing that makes a collection impossible to chase later.
+ */
+ private async resolveCollectorName(req: Request): Promise {
+ const userId = req.user?.userId;
+ if (userId) {
+ try {
+ const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, {
+ expiresIn: '1m',
+ });
+ const url = `${process.env.EMPLOYEE_SERVICE_URL || 'http://localhost:3002'}/employee/${userId}`;
+ const res = await axios.get(url, { headers: { Authorization: `Bearer ${token}` } });
+ const emp = res.data?.data ?? res.data;
+ // The employee record stores the name split in two snake_case columns; there is no
+ // single `name` field, so reading one silently yielded undefined and every
+ // collection was attributed to an email address instead of a person.
+ const full =
+ `${emp?.first_name || emp?.firstName || ''} ${emp?.last_name || emp?.lastName || ''}`.trim();
+ if (full) return full;
+ if (emp?.email) return String(emp.email);
+ } catch {
+ // Name lookup is a convenience; never block a collection on it.
+ }
+ }
+ return req.user?.email || req.user?.employeeJob || req.user?.role || 'Staff';
+ }
+
+ /** True while the charge is taken or awaiting sign-off: no one may collect it again. */
+ private isVisitChargeSettledOrPending(ticket: ServiceTicket): boolean {
+ return (
+ ticket.visitChargeCollected ||
+ ticket.visitChargeStatus === 'COLLECTED' ||
+ ticket.visitChargeStatus === 'PENDING_APPROVAL'
);
}
+ /**
+ * PATCH /service/tickets/:id/visit-charge-decision
+ *
+ * Called by billing when Accounts approve or reject the charge. Internal only.
+ */
+ applyVisitChargeDecision = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const { status, paymentRequestId, rejectionReason } = req.body || {};
+ if (status !== 'COLLECTED' && status !== 'REJECTED') {
+ throw new AppError('status must be COLLECTED or REJECTED', 400);
+ }
+ const ticketRepo = Source.getRepository(ServiceTicket);
+ const ticket = await ticketRepo.findOne({ where: { id: String(req.params.id) } });
+ if (!ticket) throw new AppError('Ticket not found', 404);
+
+ // Ignore a decision for a superseded request: a charge rejected once and collected
+ // again has a newer request, and a late callback for the old one must not undo it.
+ if (
+ paymentRequestId &&
+ ticket.visitChargeRequestId &&
+ ticket.visitChargeRequestId !== paymentRequestId
+ ) {
+ return res.status(200).json({ success: true, data: ticket, ignored: 'stale request' });
+ }
+
+ if (status === 'COLLECTED') {
+ ticket.visitChargeStatus = 'COLLECTED';
+ ticket.visitChargeCollected = true;
+ ticket.visitChargeCollectedAt = new Date();
+ ticket.visitChargeRejectionReason = null;
+ } else {
+ // The charge is owed again, so the ticket goes back to collectable and the
+ // boolean every other reader trusts stays false.
+ ticket.visitChargeStatus = 'REJECTED';
+ ticket.visitChargeCollected = false;
+ ticket.visitChargeCollectedAt = null;
+ ticket.visitChargeRejectionReason = rejectionReason || null;
+ }
+ await ticketRepo.save(ticket);
+
+ await this.logActivity(
+ ticket.id,
+ status === 'COLLECTED' ? 'VISIT_CHARGE_APPROVED' : 'VISIT_CHARGE_REJECTED',
+ status === 'COLLECTED'
+ ? `Accounts approved the visit charge of ${ticket.visitChargeAmount}.`
+ : `Accounts rejected the visit charge${rejectionReason ? `: ${rejectionReason}` : ''}. It may be collected again.`,
+ undefined,
+ );
+
+ res.status(200).json({ success: true, data: ticket });
+ } catch (error) {
+ next(error);
+ }
+ };
+
/**
* POST /service/tickets/:id/collect-visit-charge
*
@@ -4543,7 +4914,7 @@ Xerocare Technical Services`;
throw new AppError('Not authorized to collect payment for this ticket', 403);
}
- const { paymentMode, accountId } = req.body;
+ const { paymentMode, accountId, chequeNumber, chequeBankName, chequeDate } = req.body;
const id = req.params.id as string;
const ticketRepo = Source.getRepository(ServiceTicket);
const ticket = await ticketRepo.findOne({ where: { id: String(id) } });
@@ -4564,8 +4935,13 @@ Xerocare Technical Services`;
if (Number(ticket.visitChargeAmount) <= 0) {
throw new AppError('No visit charge has been quoted on this ticket yet', 400);
}
- if (ticket.visitChargeCollected) {
- throw new AppError('Visit charge already collected', 400);
+ if (this.isVisitChargeSettledOrPending(ticket)) {
+ throw new AppError(
+ ticket.visitChargeStatus === 'PENDING_APPROVAL'
+ ? 'This visit charge has already been collected and is awaiting Accounts approval.'
+ : 'Visit charge already collected',
+ 400,
+ );
}
if (!paymentMode || (paymentMode !== 'CHEQUE' && !accountId)) {
throw new AppError(
@@ -4573,44 +4949,123 @@ Xerocare Technical Services`;
400,
);
}
+ // Rejected up front rather than 500ing deep inside billing. The approval queue takes
+ // these four modes only; CREDIT_CARD is a legacy stored value, not something new
+ // payments may use.
+ const ACCEPTED_MODES = ['CASH', 'BANK_TRANSFER', 'CHEQUE'];
+ if (!ACCEPTED_MODES.includes(paymentMode)) {
+ throw new AppError(
+ `Unsupported payment mode "${paymentMode}". Use Cash, Bank Transfer or Cheque.`,
+ 400,
+ );
+ }
+ if (paymentMode === 'CHEQUE' && !chequeNumber) {
+ throw new AppError('A cheque number is required to record a cheque payment.', 400);
+ }
+
+ await this.requestVisitChargeApproval(ticket, {
+ paymentMode,
+ accountId,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
+ userId: req.user?.userId,
+ userName: await this.resolveCollectorName(req),
+ userRole: callerJob || callerRole,
+ remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected before assignment/diagnosis`,
+ });
+
+ res.status(200).json({ success: true, data: ticket });
+ } catch (error) {
+ next(error);
+ }
+ };
+
+ /**
+ * POST /service/tickets/:id/collect-completion-payment
+ *
+ * "Not collected" at completion doesn't mean never — the customer often
+ * pays after the fact. This lets staff record that later payment against
+ * an already-COMPLETED ticket, reusing the exact same billing endpoint
+ * on-the-spot completion collection uses: money is never marked collected
+ * directly here, it goes to Accounts as a pending SalePaymentRequest like
+ * every other collection path in this app, and only posts to the cashbook
+ * once approved into the account the collector chose.
+ */
+ collectCompletionPayment = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const callerRole = req.user?.role;
+ const callerJob = req.user?.employeeJob;
+ const mayCollect =
+ callerRole === 'MANAGER' ||
+ callerRole === 'ADMIN' ||
+ callerRole === 'FINANCE' ||
+ callerJob === 'SERVICE_HELP_DESK' ||
+ callerJob === 'SERVICE_TECHNICIAN';
+ if (!mayCollect) {
+ throw new AppError('Not authorized to collect payment for this ticket', 403);
+ }
+
+ const { amount, paymentMode, accountId, chequeNumber, chequeBankName, chequeDate } = req.body;
+ const id = req.params.id as string;
+ const ticketRepo = Source.getRepository(ServiceTicket);
+ const ticket = await ticketRepo.findOne({ where: { id: String(id) } });
+ if (!ticket) throw new Error('Ticket not found');
+
+ if (ticket.status !== ServiceTicketStatus.COMPLETED) {
+ throw new AppError('This ticket must be completed before collecting its balance.', 400);
+ }
+ if (!ticket.serviceQuotationId) {
+ throw new AppError('This ticket has no invoice to collect against.', 400);
+ }
+ if (!(Number(amount) > 0)) {
+ throw new AppError('Amount must be greater than 0.', 400);
+ }
+ if (!paymentMode || (paymentMode !== 'CHEQUE' && !accountId)) {
+ throw new AppError(
+ 'paymentMode (and accountId, unless paying by cheque) are required.',
+ 400,
+ );
+ }
+ const ACCEPTED_MODES = ['CASH', 'BANK_TRANSFER', 'CHEQUE'];
+ if (!ACCEPTED_MODES.includes(paymentMode)) {
+ throw new AppError(
+ `Unsupported payment mode "${paymentMode}". Use Cash, Bank Transfer or Cheque.`,
+ 400,
+ );
+ }
+ if (paymentMode === 'CHEQUE' && !chequeNumber) {
+ throw new AppError('A cheque number is required to record a cheque payment.', 400);
+ }
const token = sign({ userId: 'ven_inv_service', role: 'ADMIN' }, ACCESS_SECRET as string, {
expiresIn: '1m',
});
- await axios.post(
- `${BILLING_SERVICE_URL}/invoices/service-visit-charge`,
+ const collectorName = await this.resolveCollectorName(req);
+ const payRes = await axios.post(
+ `${BILLING_SERVICE_URL}/invoices/${ticket.serviceQuotationId}/service-completion-payment`,
{
- serviceTicketId: ticket.id,
- ticketNumber: ticket.ticketNumber,
- customerId: ticket.customerId,
- branchId: ticket.branchId,
- amount: Number(ticket.visitChargeAmount),
- collectedBy: req.user?.userId || 'SYSTEM',
+ amount: Number(amount),
paymentMode,
accountId,
- remarks: `Service Visit Charge — Ticket ${ticket.ticketNumber} — collected before assignment/diagnosis`,
+ chequeNumber,
+ chequeBankName,
+ chequeDate,
+ branchId: ticket.branchId,
+ collectedBy: req.user?.userId,
+ remarks: `Service completion payment — Ticket ${ticket.ticketNumber} — collected after completion by ${collectorName}`,
},
{ headers: { Authorization: `Bearer ${token}` } },
);
- ticket.visitChargeCollected = true;
- ticket.visitChargeCollectedAt = new Date();
- // Mark it the same way the on-site "pay now" path does — a stand-alone
- // collected charge, not deferred onto the estimate — so anything that
- // keys off visitChargeMethod (finance reporting, the diagnosis-time
- // "already collected" guard) treats it consistently either way.
- if (!ticket.visitChargeMethod) {
- ticket.visitChargeMethod = 'SEPARATE';
- }
- await ticketRepo.save(ticket);
await this.logActivity(
ticket.id,
- 'VISIT_CHARGE_COLLECTED',
- `Visit charge of ${ticket.visitChargeAmount} collected up front and posted to accounts.`,
+ 'COMPLETION_PAYMENT_COLLECTED',
+ `${amount} collected by ${paymentMode} after completion — sent to Accounts for approval (${payRes.data?.data?.requestNo ?? 'request raised'}).`,
req.user?.userId,
);
- res.status(200).json({ success: true, data: ticket });
+ res.status(200).json({ success: true, data: payRes.data?.data });
} catch (error) {
next(error);
}
@@ -4699,6 +5154,8 @@ Xerocare Technical Services`;
ticket,
{ collectVisitCharge, paymentMode, accountId },
req.user?.userId,
+ await this.resolveCollectorName(req),
+ req.user?.employeeJob || req.user?.role,
);
} catch (err) {
logger.error('Failed to collect visit charge at customer rejection:', err);
@@ -4770,8 +5227,16 @@ Xerocare Technical Services`;
*/
registerExternalMachine = async (req: Request, res: Response, next: NextFunction) => {
try {
- const { customerId, brand, modelName, serialNumber, meterReading, printColour, description } =
- req.body;
+ const {
+ customerId,
+ brand,
+ modelName,
+ serialNumber,
+ meterReading,
+ printColour,
+ description,
+ machineType,
+ } = req.body;
if (!customerId) throw new AppError('customerId is required.', 400);
if (!brand || !String(brand).trim()) throw new AppError('Brand is required.', 400);
@@ -4779,9 +5244,20 @@ Xerocare Technical Services`;
throw new AppError('Model name is required.', 400);
if (!serialNumber || !String(serialNumber).trim())
throw new AppError('Serial number is required.', 400);
- const meter = Number(meterReading);
- if (meterReading === undefined || meterReading === '' || isNaN(meter) || meter < 0) {
- throw new AppError('Current meter reading is required (0 or more).', 400);
+
+ const resolvedMachineType = Object.values(MachineType).includes(machineType as MachineType)
+ ? (machineType as MachineType)
+ : MachineType.PRINTER;
+ const metered = isMeteredMachine(resolvedMachineType);
+
+ // Only a metered machine (printer/copier) needs a meter reading at all —
+ // a computer has no page count to track.
+ let meter = 0;
+ if (metered) {
+ meter = Number(meterReading);
+ if (meterReading === undefined || meterReading === '' || isNaN(meter) || meter < 0) {
+ throw new AppError('Current meter reading is required (0 or more).', 400);
+ }
}
const productRepo = Source.getRepository(Product);
@@ -4807,6 +5283,7 @@ Xerocare Technical Services`;
ownership: OwnershipType.EXTERNAL,
customer_id: customerId,
meter_reading: meter,
+ machine_type: resolvedMachineType,
// Not our stock — keep it out of available inventory
product_status: ProductStatus.SOLD,
tax_rate: 0,
@@ -4948,6 +5425,44 @@ Xerocare Technical Services`;
if (!startDate) throw new AppError('startDate is required.', 400);
const fields = this.buildContractFields(req.body);
+
+ // A computer (or any other non-metered machine) has no page count to bill by,
+ // so SMA/FSMA — both priced per click — make no sense for it. Only AMC's flat
+ // fee applies. Printers keep all three.
+ const machine = await Source.getRepository(Product).findOne({
+ where: { id: String(productId) },
+ });
+ if (
+ machine &&
+ !isMeteredMachine(machine.machine_type) &&
+ fields.contractType !== ServiceContractType.AMC
+ ) {
+ throw new AppError(
+ `${machine.machine_type === MachineType.COMPUTER ? 'Computers' : 'This machine type'} can only be enrolled in an AMC contract.`,
+ 400,
+ );
+ }
+
+ // A new contract's starting meter can never be entered lower than the machine's
+ // own last-known reading — e.g. a renewal after a prior contract must pick up
+ // where that one left off, not silently roll the meter backwards.
+ if (machine?.meter_reading != null) {
+ const currentMeter = Number(machine.meter_reading);
+ const startTotal =
+ fields.contractType === ServiceContractType.FSMA &&
+ fields.fsmaBillingMode === 'INDIVIDUAL'
+ ? Number(fields.startMeterBW || 0) + Number(fields.startMeterColor || 0)
+ : fields.startMeterReading != null
+ ? Number(fields.startMeterReading)
+ : null;
+ if (startTotal != null && startTotal < currentMeter) {
+ throw new AppError(
+ `Starting meter (${startTotal}) cannot be less than the machine's current meter reading (${currentMeter}).`,
+ 400,
+ );
+ }
+ }
+
const start = new Date(startDate);
// Contracts default to a 1-year term
const end = endDate
@@ -5110,6 +5625,7 @@ Xerocare Technical Services`;
modelName: p.model?.model_name || p.name,
brand: p.brand,
serialNumber: p.serial_no,
+ machineType: p.machine_type,
}
: null,
usageSummary: {
@@ -5227,6 +5743,7 @@ Xerocare Technical Services`;
serialNumber: product.serial_no,
ownership: product.ownership || null,
meterReading: product.meter_reading ?? null,
+ machineType: product.machine_type,
}
: null,
readings,
@@ -5260,6 +5777,21 @@ Xerocare Technical Services`;
// values) so a type change can never leave stale billing fields behind.
const merged = { ...contract, ...req.body };
const fields = this.buildContractFields(merged);
+
+ const machine = await Source.getRepository(Product).findOne({
+ where: { id: contract.productId },
+ });
+ if (
+ machine &&
+ !isMeteredMachine(machine.machine_type) &&
+ fields.contractType !== ServiceContractType.AMC
+ ) {
+ throw new AppError(
+ `${machine.machine_type === MachineType.COMPUTER ? 'Computers' : 'This machine type'} can only be enrolled in an AMC contract.`,
+ 400,
+ );
+ }
+
Object.assign(contract, fields);
// A contract is bound to its customer and machine for life — reject swaps.
@@ -5343,6 +5875,13 @@ Xerocare Technical Services`;
throw new AppError(`Cannot record readings on a ${contract.status} contract.`, 400);
}
+ const machine = await Source.getRepository(Product).findOne({
+ where: { id: contract.productId },
+ });
+ if (machine && !isMeteredMachine(machine.machine_type)) {
+ throw new AppError('This machine has no usage meter — meter readings do not apply.', 400);
+ }
+
const readingRepo = Source.getRepository(ContractMeterReading);
const lastReading = await readingRepo.findOne({
where: { contractId: contract.id },
@@ -5665,12 +6204,28 @@ Xerocare Technical Services`;
const serialReadings = serialTickets.map((t) =>
Math.max(Number(t.meterReadingAtService) || 0, Number(t.meterReadingAtCreation) || 0),
);
+ const serialTicketIds = serialTickets.map((t) => t.id);
+ const latestEstimateCostBySerialTicket = new Map();
+ if (serialTicketIds.length) {
+ const estimates = await Source.getRepository(ServiceEstimate).find({
+ where: { ticketId: In(serialTicketIds) },
+ order: { version: 'DESC' },
+ });
+ for (const est of estimates) {
+ if (!latestEstimateCostBySerialTicket.has(est.ticketId)) {
+ latestEstimateCostBySerialTicket.set(est.ticketId, Number(est.totalCost) || 0);
+ }
+ }
+ }
return res.status(200).json({
success: true,
data: {
history: null,
currentMeterReading: serialReadings.length ? Math.max(...serialReadings) : null,
- tickets: serialTickets,
+ tickets: serialTickets.map((t) => ({
+ ...t,
+ estimateTotalCost: latestEstimateCostBySerialTicket.get(t.id) ?? null,
+ })),
partLogs: [],
yields: [],
},
@@ -5694,6 +6249,28 @@ Xerocare Technical Services`;
order: { replacedAt: 'DESC' },
});
+ // Per-visit cost shown in the history list should reflect the latest
+ // estimate's total (parts + labour + visit/transport - discount), not
+ // just billed ticket items — a ticket can carry its whole cost in the
+ // estimate (labour/visit charge) with zero items.
+ const ticketIds = tickets.map((t) => t.id);
+ const latestEstimateCostByTicket = new Map();
+ if (ticketIds.length) {
+ const estimates = await Source.getRepository(ServiceEstimate).find({
+ where: { ticketId: In(ticketIds) },
+ order: { version: 'DESC' },
+ });
+ for (const est of estimates) {
+ if (!latestEstimateCostByTicket.has(est.ticketId)) {
+ latestEstimateCostByTicket.set(est.ticketId, Number(est.totalCost) || 0);
+ }
+ }
+ }
+ const ticketsWithEstimateCost = tickets.map((t) => ({
+ ...t,
+ estimateTotalCost: latestEstimateCostByTicket.get(t.id) ?? null,
+ }));
+
const yieldRepo = Source.getRepository(ConsumableYieldHistory);
const yields = await yieldRepo.find({
where: { productId: productId },
@@ -5726,7 +6303,7 @@ Xerocare Technical Services`;
data: {
history: history || null,
currentMeterReading: currentMeterReading || null,
- tickets,
+ tickets: ticketsWithEstimateCost,
partLogs,
yields,
},
@@ -5997,6 +6574,8 @@ Xerocare Technical Services`;
let emailSent = false;
let whatsappSent = false;
+ let emailError: string | null = null;
+ let whatsappError: string | null = null;
if (emailToUse) {
const subject = `Service Quotation - ${ticket.ticketNumber}`;
@@ -6017,14 +6596,23 @@ ${approvalLink}
Best regards,
Xerocare Technical Services`;
- await sendServicePdfEmail(
- emailToUse,
- subject,
- bodyText,
- pdfBuffer,
- `Quotation_${ticket.ticketNumber}.pdf`,
- );
- emailSent = true;
+ // A channel that fails must not sink the whole send. Before this, an SMTP
+ // problem threw past everything and the caller got a bare "Internal server
+ // error" — no way to tell whether the WhatsApp had gone out, whether the customer
+ // had been contacted at all, or what to fix.
+ try {
+ await sendServicePdfEmail(
+ emailToUse,
+ subject,
+ bodyText,
+ pdfBuffer,
+ `Quotation_${ticket.ticketNumber}.pdf`,
+ );
+ emailSent = true;
+ } catch (err) {
+ emailError = describeSendFailure(err);
+ logger.error(`Quotation email failed for ticket ${ticket.ticketNumber}:`, err);
+ }
}
if (phoneToUse) {
@@ -6046,8 +6634,25 @@ Review & approve your quotation (link valid 72 hours): ${approvalLink}
For queries contact us at +974 4455 6677`;
- await sendWhatsappMessage(phoneToUse, message);
- whatsappSent = true;
+ try {
+ await sendWhatsappMessage(phoneToUse, message);
+ whatsappSent = true;
+ } catch (err) {
+ whatsappError = describeSendFailure(err);
+ logger.error(`Quotation WhatsApp failed for ticket ${ticket.ticketNumber}:`, err);
+ }
+ }
+
+ // Nothing reached the customer: that is a failure, and the reason is the useful
+ // part of it. 502, not 500 — the request was fine, the mail/WhatsApp provider was not.
+ if (!emailSent && !whatsappSent) {
+ return res.status(502).json({
+ success: false,
+ message: `The quotation could not be delivered. ${[emailError, whatsappError]
+ .filter(Boolean)
+ .join(' ')}`.trim(),
+ data: { emailSent, whatsappSent, emailError, whatsappError },
+ });
}
await this.logActivity(
@@ -6258,24 +6863,8 @@ For queries contact us at +974 4455 6677`;
});
await revisionRepo.save(newRevision);
- // Coverage is per item category under contracts (SMA/AMC charge toner,
- // AMC charges parts). Warranty mirrors SMA (toner chargeable); RENT and
- // CPC leases stay fully covered. Computed BEFORE any mutation so validation can reject early.
- const baseFreeContext =
- ticket.serviceContext === ServiceContext.RENT ||
- ticket.serviceContext === ServiceContext.LEASE_CPC;
- const warrantyContext = [
- ServiceContext.WARRANTY,
- ServiceContext.LEASE_UNDER_WARRANTY,
- ].includes(ticket.serviceContext);
- const reviseCoverage: ContractCoverage = baseFreeContext
- ? { ...FULL_COVERAGE }
- : warrantyContext
- ? { ...WARRANTY_COVERAGE }
- : ((await this.getTicketContractCoverage(ticket.contractReferenceId)) ?? {
- ...NO_COVERAGE,
- });
- const hasContractContext = baseFreeContext || warrantyContext || !!ticket.contractReferenceId;
+ // Computed BEFORE any mutation so validation can reject early.
+ const reviseCoverage = await this.resolveItemCoverage(ticket);
const revisePartCategories = await this.getPartCategories(
items.map((it: ReviseEstimateItem) => it.sparePartId),
);
@@ -6293,13 +6882,11 @@ For queries contact us at +974 4455 6677`;
}
const billingItems = items.map((it: ReviseEstimateItem) => {
- const covered = hasContractContext
- ? coverageAllowsItem(reviseCoverage, {
- partCategory: it.sparePartId ? revisePartCategories.get(it.sparePartId) : null,
- partName: it.partName || it.description,
- })
- : false;
- const isFree = covered || !!it.isFree;
+ const isFree = this.resolveItemIsFree(reviseCoverage, {
+ partCategory: it.sparePartId ? revisePartCategories.get(it.sparePartId) : null,
+ partName: it.partName || it.description,
+ isFree: it.isFree,
+ });
const part = it.sparePartId ? revisePartsById.get(it.sparePartId) : undefined;
const brand = part?.brand || it.customPartBrand || null;
const mpn = part?.mpn || it.mpn || null;
@@ -6315,8 +6902,7 @@ For queries contact us at +974 4455 6677`;
});
// Travel-covered contexts never pay a visit charge, revisions included.
- const reviseEffectiveVisitCharge =
- hasContractContext && reviseCoverage.travel ? 0 : Number(visitChargeAmount) || 0;
+ const reviseEffectiveVisitCharge = reviseCoverage.travel ? 0 : Number(visitChargeAmount) || 0;
// Discount is capped by the whole estimate total, not per-part limits.
const reviseItemsTotal = billingItems.reduce(
@@ -6337,8 +6923,15 @@ For queries contact us at +974 4455 6677`;
const ticketItemRepo = Source.getRepository(ServiceTicketItem);
await ticketItemRepo.delete({ ticketId: ticket.id });
+ let reviseHasCustomItem = false;
const newTicketItems = items.map((it: ReviseEstimateItem) => {
const part = it.sparePartId ? revisePartsById.get(it.sparePartId) : undefined;
+ const quantity = Number(it.quantity) || 1;
+ let unitCost = part ? Number(part.purchase_price) || 0 : 0;
+ if (!part) {
+ unitCost = Number(it.customPartCost) || 0;
+ reviseHasCustomItem = true;
+ }
return ticketItemRepo.create({
ticketId: ticket.id,
itemSource: it.itemSource || ServiceItemSource.SPARE_PART,
@@ -6347,14 +6940,26 @@ For queries contact us at +974 4455 6677`;
partName: it.partName || it.description || 'Spare Part',
partBrand: part?.brand || it.customPartBrand || null,
mpn: part?.mpn || it.mpn || null,
- quantity: it.quantity,
+ quantity,
unitPrice: Number(it.unitPrice) || 0,
- totalPrice: (Number(it.quantity) || 1) * (Number(it.unitPrice) || 0),
+ totalPrice: quantity * (Number(it.unitPrice) || 0),
isFree: !!it.isFree,
+ unitCost,
+ totalCost: unitCost * quantity,
});
});
await ticketItemRepo.save(newTicketItems);
+ if (reviseHasCustomItem) {
+ await this.notifyBranchManagerAndAdmins(ticket.branchId, {
+ title: 'Spare Part Procurement Needed',
+ message: `Ticket ${ticket.ticketNumber} used a custom (off-catalog) part — check if it needs to be procured via RFQ.`,
+ type: 'ACTION_REQUIRED',
+ referenceId: ticket.id,
+ referenceType: 'CUSTOM_PART_REQUEST',
+ });
+ }
+
// Update ticket fields
ticket.status = ServiceTicketStatus.WAITING_FINANCE_APPROVAL;
ticket.visitChargeAmount = reviseEffectiveVisitCharge;
diff --git a/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts b/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts
index 337a04be..85aa8713 100644
--- a/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts
+++ b/backend/ven_inv_service/src/entities/machineServiceHistoryEntity.ts
@@ -5,10 +5,12 @@ export class MachineServiceHistory {
@PrimaryGeneratedColumn('uuid')
id!: string;
- @Column({ type: 'uuid', unique: true })
- productId!: string; // Links to Product entity
+ // Nullable: external machines (never purchased from us, no matching Product
+ // row) still get a history row, keyed by serialNumber alone.
+ @Column({ type: 'uuid', unique: true, nullable: true })
+ productId!: string | null;
- @Column({ type: 'varchar' })
+ @Column({ type: 'varchar', unique: true })
serialNumber!: string;
@Column({ type: 'int', default: 0 })
diff --git a/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts b/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts
index f1f414f7..f82ffc4a 100644
--- a/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts
+++ b/backend/ven_inv_service/src/entities/serviceEstimateItemEntity.ts
@@ -70,6 +70,15 @@ export class ServiceEstimateItem {
@Column({ type: 'boolean', default: true })
isApproved!: boolean;
+ // Real internal cost — mirrors ServiceTicketItem.unitCost/totalCost, carried
+ // onto the estimate (and estimate revisions, via revisionId) so finance can
+ // see true spend even on a zero-price FOC line.
+ @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
+ unitCost?: number | null;
+
+ @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
+ totalCost?: number | null;
+
@CreateDateColumn()
created_at!: Date;
diff --git a/backend/ven_inv_service/src/entities/serviceTicketEntity.ts b/backend/ven_inv_service/src/entities/serviceTicketEntity.ts
index d5f16833..fc66e8b2 100644
--- a/backend/ven_inv_service/src/entities/serviceTicketEntity.ts
+++ b/backend/ven_inv_service/src/entities/serviceTicketEntity.ts
@@ -225,6 +225,53 @@ export class ServiceTicket {
@Column({ name: 'technician_note_to_finance', type: 'text', nullable: true, default: null })
technicianNoteToFinance!: string | null;
+ /**
+ * Where the visit charge has got to.
+ *
+ * NONE nobody has taken it yet — the collect action is offered
+ * PENDING_APPROVAL someone took the money; Accounts has not signed it off
+ * COLLECTED Accounts approved and the money is posted
+ * REJECTED Accounts refused it; the charge is owed again
+ *
+ * `visitChargeCollected` stays as the single boolean every existing reader already
+ * keys off (billing reports, the diagnosis-time "already collected" guard) and is true
+ * only for COLLECTED. Money that is merely awaiting approval must not read as collected
+ * anywhere, or the charge silently disappears from what is still owed.
+ */
+ @Column({ name: 'visit_charge_status', type: 'varchar', length: 20, default: 'NONE' })
+ visitChargeStatus!: 'NONE' | 'PENDING_APPROVAL' | 'COLLECTED' | 'REJECTED';
+
+ /** The SalePaymentRequest in billing that Accounts acts on. */
+ @Column({ name: 'visit_charge_request_id', type: 'uuid', nullable: true, default: null })
+ visitChargeRequestId!: string | null;
+
+ // Who physically took the money, kept on the ticket so the desk can see it without a
+ // round trip to billing — and so a rejected charge names the person to go back to.
+ @Column({ name: 'visit_charge_collected_by', type: 'uuid', nullable: true, default: null })
+ visitChargeCollectedBy!: string | null;
+
+ @Column({
+ name: 'visit_charge_collected_by_name',
+ type: 'varchar',
+ length: 255,
+ nullable: true,
+ default: null,
+ })
+ visitChargeCollectedByName!: string | null;
+
+ /** SERVICE_HELP_DESK or SERVICE_TECHNICIAN — which desk took it. */
+ @Column({
+ name: 'visit_charge_collected_by_role',
+ type: 'varchar',
+ length: 40,
+ nullable: true,
+ default: null,
+ })
+ visitChargeCollectedByRole!: string | null;
+
+ @Column({ name: 'visit_charge_rejection_reason', type: 'text', nullable: true, default: null })
+ visitChargeRejectionReason!: string | null;
+
@Column({ name: 'visit_charge_collected', type: 'boolean', default: false })
visitChargeCollected!: boolean;
diff --git a/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts b/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts
index 0a63df92..4c53e3ce 100644
--- a/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts
+++ b/backend/ven_inv_service/src/entities/serviceTicketItemEntity.ts
@@ -73,6 +73,16 @@ export class ServiceTicketItem {
@Column({ type: 'boolean', default: false })
isFree!: boolean;
+ // Real internal cost — what the business actually spent on this item, even
+ // when it's priced free to the customer (unitPrice/totalPrice above are 0).
+ // For catalog parts, sourced from SparePart.purchase_price; for CUSTOM
+ // (off-catalog) items, technician-entered at diagnosis/estimate time.
+ @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
+ unitCost?: number | null;
+
+ @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true })
+ totalCost?: number | null;
+
@CreateDateColumn()
created_at!: Date;
diff --git a/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts b/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts
index 3be58e54..ef6db903 100644
--- a/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts
+++ b/backend/ven_inv_service/src/events/publisher/notificationPublisher.ts
@@ -25,7 +25,8 @@ export class NotificationPublisher {
| 'SERVICE'
| 'SERVICE_TICKET'
| 'SERVICE_CONTRACT'
- | 'STOCK_TRANSFER';
+ | 'STOCK_TRANSFER'
+ | 'CUSTOM_PART_REQUEST';
}) {
try {
const channel = await getRabbitChannel();
diff --git a/backend/ven_inv_service/src/routes/brandRoute.ts b/backend/ven_inv_service/src/routes/brandRoute.ts
index e98ff0f8..c4e4a8eb 100644
--- a/backend/ven_inv_service/src/routes/brandRoute.ts
+++ b/backend/ven_inv_service/src/routes/brandRoute.ts
@@ -4,14 +4,25 @@ import { BrandService } from '../services/brandService';
import { BrandRepository } from '../repositories/brandRepository';
import { Source } from '../config/db';
import { authMiddleware } from '../middlewares/authMiddleware';
-import { roleMiddleware } from '../middlewares/roleMiddleware';
+import { roleMiddleware, requireServiceRole } from '../middlewares/roleMiddleware';
const router = Router();
const brandRepo = new BrandRepository(Source);
const brandService = new BrandService(brandRepo);
const brandController = new BrandController(brandService);
-router.post('/', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), brandController.createBrand);
+// Creating a brand is additive and low-risk (unlike editing/deleting one that
+// may already be in use elsewhere), and is a routine sub-step of registering
+// an external machine for a service ticket or contract — a flow already open
+// to technicians/help desk with no role gate at all. Restricting just this
+// one step to ADMIN/MANAGER blocked that flow outright; scoped to the jobs
+// that actually hit it, rather than opening it to every employee.
+router.post(
+ '/',
+ authMiddleware,
+ requireServiceRole(['SERVICE_TECHNICIAN', 'SERVICE_HELP_DESK']),
+ brandController.createBrand,
+);
router.get(
'/',
diff --git a/backend/ven_inv_service/src/routes/modelRoute.ts b/backend/ven_inv_service/src/routes/modelRoute.ts
index 5c092cf4..30a63964 100644
--- a/backend/ven_inv_service/src/routes/modelRoute.ts
+++ b/backend/ven_inv_service/src/routes/modelRoute.ts
@@ -9,13 +9,22 @@ import {
} from '../controllers/modelController';
import { authMiddleware } from '../middlewares/authMiddleware';
-import { roleMiddleware } from '../middlewares/roleMiddleware';
+import { roleMiddleware, requireServiceRole } from '../middlewares/roleMiddleware';
const modelRoute = Router();
modelRoute.get('/', authMiddleware, getallModels);
modelRoute.get('/:id', authMiddleware, getModelById);
-modelRoute.post('/', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), addModel);
+// Same reasoning as brandRoute.ts's createBrand: additive, low-risk, and a
+// routine sub-step of registering an external machine for a service ticket
+// or contract — scoped to the jobs that actually hit that flow rather than
+// opened to every employee.
+modelRoute.post(
+ '/',
+ authMiddleware,
+ requireServiceRole(['SERVICE_TECHNICIAN', 'SERVICE_HELP_DESK']),
+ addModel,
+);
modelRoute.put('/:id', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), editModel);
modelRoute.delete('/:id', authMiddleware, roleMiddleware(['ADMIN', 'MANAGER']), deleteModel);
modelRoute.post(
diff --git a/backend/ven_inv_service/src/routes/serviceRoutes.ts b/backend/ven_inv_service/src/routes/serviceRoutes.ts
index 85143e33..a5db5ba9 100644
--- a/backend/ven_inv_service/src/routes/serviceRoutes.ts
+++ b/backend/ven_inv_service/src/routes/serviceRoutes.ts
@@ -37,6 +37,9 @@ router.get('/contracts/:id/bills', controller.getContractBills);
router.post('/external-machines', controller.registerExternalMachine);
router.post('/tickets/:id/assign', controller.assignTechnician);
router.post('/tickets/:id/collect-visit-charge', controller.collectVisitCharge);
+router.post('/tickets/:id/collect-completion-payment', controller.collectCompletionPayment);
+// Internal: billing reports what Accounts decided about a collected visit charge.
+router.patch('/tickets/:id/visit-charge-decision', controller.applyVisitChargeDecision);
router.post(
'/tickets/:id/start-diagnosis',
requireServiceRole(['SERVICE_TECHNICIAN']),
@@ -159,6 +162,7 @@ router.get('/customers/:customerId/history', controller.getCustomerHistory);
router.get('/machines/:serialNumber/lifetime-cost', controller.getMachineLifetimeCost);
router.get('/machines/:serialNumber/context', controller.getMachineContext);
router.get('/machines/:serialNumber/yield-history', controller.getMachineYieldHistory);
+router.get('/machines/:serialNumber/analytics', controller.getMachineAnalytics);
router.get('/finance/dashboard', controller.getFinanceDashboard);
router.post('/spare-parts/:id/mark-damaged', controller.markSparePartDamaged);
diff --git a/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts b/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts
index 414b1999..046f08c9 100644
--- a/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts
+++ b/backend/ven_inv_service/src/services/preventativeMaintenanceJob.ts
@@ -132,6 +132,7 @@ export async function runPreventativeMaintenanceJob() {
ServiceTicketStatus.ASSIGNED,
ServiceTicketStatus.DIAGNOSED,
ServiceTicketStatus.IN_PROGRESS,
+ ServiceTicketStatus.WAITING_FINANCE_APPROVAL,
ServiceTicketStatus.FREE_SERVICE,
]),
},
@@ -151,7 +152,7 @@ export async function runPreventativeMaintenanceJob() {
serviceContext: ServiceContext.RENT,
ticketType: 'PREVENTATIVE_MAINTENANCE',
jobType: 'WARRANTY_ONSITE' as JobType,
- status: ServiceTicketStatus.FREE_SERVICE,
+ status: ServiceTicketStatus.OPEN,
track: 'A',
issueDescription: 'Scheduled monthly preventative maintenance service.',
branchId,
diff --git a/backend/ven_inv_service/src/utils/emailService.ts b/backend/ven_inv_service/src/utils/emailService.ts
index 814711cf..61b01eb2 100644
--- a/backend/ven_inv_service/src/utils/emailService.ts
+++ b/backend/ven_inv_service/src/utils/emailService.ts
@@ -1,4 +1,5 @@
import nodemailer from 'nodemailer';
+import { logger } from '../config/logger';
// Same transport pattern as employee_service/src/utils/mailer.ts — the one
// actually wired to working credentials in every .env in this repo
@@ -24,7 +25,7 @@ export async function sendServicePdfEmail(
pdfBuffer: Buffer,
filename: string,
): Promise {
- await getTransporter().sendMail({
+ const info = await getTransporter().sendMail({
from: `Xerocare Technical Services <${process.env.MAIL_USER}>`,
to: toEmail,
subject,
@@ -37,6 +38,13 @@ export async function sendServicePdfEmail(
},
],
});
+ // sendMail resolving doesn't guarantee inbox delivery, but it does confirm
+ // Gmail's SMTP accepted the recipient — logging this (previously silent)
+ // is the only way to tell "we sent it, check spam" apart from "we never
+ // actually sent it" when a customer says they got nothing.
+ logger.info(
+ `Sent "${subject}" to ${toEmail} — accepted: ${JSON.stringify(info.accepted)}, rejected: ${JSON.stringify(info.rejected)}, messageId: ${info.messageId}`,
+ );
}
/** Plain-text email, no attachment — e.g. the ticket-creation confirmation. */
@@ -45,10 +53,13 @@ export async function sendServiceEmail(
subject: string,
bodyText: string,
): Promise {
- await getTransporter().sendMail({
+ const info = await getTransporter().sendMail({
from: `Xerocare Technical Services <${process.env.MAIL_USER}>`,
to: toEmail,
subject,
text: bodyText,
});
+ logger.info(
+ `Sent "${subject}" to ${toEmail} — accepted: ${JSON.stringify(info.accepted)}, rejected: ${JSON.stringify(info.rejected)}, messageId: ${info.messageId}`,
+ );
}
diff --git a/frontend/app/admin/(dashboard)/customers/[id]/page.tsx b/frontend/app/admin/(dashboard)/customers/[id]/page.tsx
index e8d2e799..24efb9dd 100644
--- a/frontend/app/admin/(dashboard)/customers/[id]/page.tsx
+++ b/frontend/app/admin/(dashboard)/customers/[id]/page.tsx
@@ -143,6 +143,7 @@ function Customer360Content() {
createdByName={createdByName}
createdByRole={createdByEmployee?.role}
backHref="/admin/customers"
+ productBasePath="/admin"
/>
);
diff --git a/frontend/app/admin/(dashboard)/products/[id]/page.tsx b/frontend/app/admin/(dashboard)/products/[id]/page.tsx
index a40517e4..2ea1f7b3 100644
--- a/frontend/app/admin/(dashboard)/products/[id]/page.tsx
+++ b/frontend/app/admin/(dashboard)/products/[id]/page.tsx
@@ -36,6 +36,7 @@ import Barcode from 'react-barcode';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { getProductHistory, ProductHistoryResponse, HistoryEvent } from '@/lib/productHistory';
import { resolveImageUrl } from '@/lib/imageUrl';
+import MachineServiceAnalyticsPanel from '@/components/products/MachineServiceAnalyticsPanel';
interface ProductFeature {
subHeading: string;
@@ -250,7 +251,12 @@ export default function ProductDetailPage() {
-
+
@@ -721,9 +727,13 @@ function EventCard({ event }: { event: HistoryEvent }) {
function LifecycleTab({
history,
loading,
+ serialNumber,
+ currency,
}: {
history: ProductHistoryResponse | null;
loading: boolean;
+ serialNumber?: string | null;
+ currency?: string;
}) {
if (loading) {
return (
@@ -782,6 +792,15 @@ function LifecycleTab({
)}
+ {/* Real internal spend + per-ticket/toner breakdown — additive to the
+ summary above, works for external machines too (keyed by serial). */}
+
+
+ Service & Spend Detail
+
+
+
+
{/* Timeline */}
{history.events.length === 0 ? (
diff --git a/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx b/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx
index eaff4d98..a7fdbdbf 100644
--- a/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx
+++ b/frontend/app/employee/(dashboard)/service/contracts/[id]/page.tsx
@@ -55,6 +55,7 @@ import {
DollarSign,
Send,
Eye,
+ Clock,
} from 'lucide-react';
const COVERAGE_LABELS: Array<{ key: keyof ContractCoverage; label: string }> = [
@@ -201,6 +202,7 @@ export default function ServiceContractDetailPage() {
paymentDate: payForm.paymentDate,
referenceNumber: payForm.referenceNumber || undefined,
remarks: payForm.remarks || undefined,
+ paymentContext: 'SERVICE_CONTRACT_INSTALLMENT',
// Card facts only — the processing fee is priced server-side from the
// configured merchant agreement, never sent from here.
...(payForm.paymentMode === 'ONLINE_PAYMENT'
@@ -448,6 +450,15 @@ export default function ServiceContractDetailPage() {
Paid {currency} {paymentSummary.totalPaid.toFixed(2)} · Balance {currency}{' '}
{paymentSummary.pendingBalance.toFixed(2)}
+ {paymentSummary.pendingApprovalCount > 0 && (
+
+ {paymentSummary.pendingApprovalCount === 1
+ ? '1 payment'
+ : `${paymentSummary.pendingApprovalCount} payments`}{' '}
+ ({currency} {paymentSummary.pendingApprovalAmount.toFixed(2)}) awaiting Finance
+ approval
+
+ )}
>
)}
@@ -637,67 +648,75 @@ export default function ServiceContractDetailPage() {
- {/* Meter readings */}
-
-
-
- Meter Readings (
- {contract.readings.length})
-
-
-
- {contract.readings.length === 0 ? (
- No readings recorded yet.
- ) : (
-
-
-
-
- Date
- Meter
-
- Clicks
-
-
- Charged
-
- Notes
-
-
-
- {contract.readings.map((r) => (
-
-
- {fmtDate(r.readingDate)}
-
-
- {r.totalReading != null
- ? Number(r.totalReading).toLocaleString()
- : r.bwReading != null || r.colorReading != null
- ? `BW ${Number(r.bwReading ?? 0).toLocaleString()} · Col ${Number(r.colorReading ?? 0).toLocaleString()}`
- : '—'}
-
-
- {Number(r.clicksTotal || 0).toLocaleString()}
-
-
- {contract.contractType === 'AMC' ? (
- tracking only
- ) : (
- `${currency} ${Number(r.amountCharged || 0).toFixed(2)}`
- )}
-
-
- {r.notes || '—'}
-
-
- ))}
-
-
-
- )}
-
-
+ {/* Meter readings — not applicable to a computer/non-metered machine, which has
+ no page count to track at all. */}
+ {contract.machine?.machineType !== 'COMPUTER' &&
+ contract.machine?.machineType !== 'OTHER' && (
+
+
+
+ Meter Readings (
+ {contract.readings.length})
+
+
+
+ {contract.readings.length === 0 ? (
+ No readings recorded yet.
+ ) : (
+
+
+
+
+ Date
+
+ Meter
+
+
+ Clicks
+
+
+ Charged
+
+
+ Notes
+
+
+
+
+ {contract.readings.map((r) => (
+
+
+ {fmtDate(r.readingDate)}
+
+
+ {r.totalReading != null
+ ? Number(r.totalReading).toLocaleString()
+ : r.bwReading != null || r.colorReading != null
+ ? `BW ${Number(r.bwReading ?? 0).toLocaleString()} · Col ${Number(r.colorReading ?? 0).toLocaleString()}`
+ : '—'}
+
+
+ {Number(r.clicksTotal || 0).toLocaleString()}
+
+
+ {contract.contractType === 'AMC' ? (
+ tracking only
+ ) : (
+ `${currency} ${Number(r.amountCharged || 0).toFixed(2)}`
+ )}
+
+
+ {r.notes || '—'}
+
+
+ ))}
+
+
+
+ )}
+
+
+ )}
{/* Monthly bills — FSMA only. Generated automatically once a month by
the billing sweep; casual readings in between accrue unbilled. */}
@@ -864,14 +883,28 @@ export default function ServiceContractDetailPage() {
{paymentSummary && (
-
-
- Paid {currency} {paymentSummary.totalPaid.toFixed(2)} of {currency}{' '}
- {paymentSummary.totalAmount.toFixed(2)}
-
-
- Balance {currency} {paymentSummary.pendingBalance.toFixed(2)}
-
+
+
+
+ Paid {currency} {paymentSummary.totalPaid.toFixed(2)} of {currency}{' '}
+ {paymentSummary.totalAmount.toFixed(2)}
+
+
+ Balance {currency} {paymentSummary.pendingBalance.toFixed(2)}
+
+
+ {paymentSummary.pendingApprovalCount > 0 && (
+
+
+
+ {paymentSummary.pendingApprovalCount === 1
+ ? '1 payment'
+ : `${paymentSummary.pendingApprovalCount} payments`}{' '}
+ ({currency} {paymentSummary.pendingApprovalAmount.toFixed(2)}) awaiting Finance
+ approval — not yet reflected in the balance above.
+
+
+ )}
)}
diff --git a/frontend/app/employee/(dashboard)/service/contracts/page.tsx b/frontend/app/employee/(dashboard)/service/contracts/page.tsx
index b7016431..1d9f8400 100644
--- a/frontend/app/employee/(dashboard)/service/contracts/page.tsx
+++ b/frontend/app/employee/(dashboard)/service/contracts/page.tsx
@@ -4,8 +4,10 @@ import React, { useEffect, useState } from 'react';
import { getCustomers, createCustomer, Customer, CreateCustomerData } from '@/lib/customer';
import CustomerFormDialog from '@/components/employeeComponents/CustomerFormDialog';
import { getAllProducts, Product } from '@/lib/product';
-import { getBrands, createBrand, Brand } from '@/lib/brand';
-import { getAllModels, addModel, Model } from '@/lib/model';
+import { getBrands, Brand } from '@/lib/brand';
+import { getAllModels, Model } from '@/lib/model';
+import { AddBrandDialog } from '@/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog';
+import { AddModelDialog } from '@/components/ManagerDashboardComponents/productComponents/AddModelDialog';
import { CustomerServiceHistory, WarrantyInfo } from '@/lib/serviceTicket';
import {
getServiceContracts,
@@ -26,7 +28,7 @@ import { getAccountSummary, PaymentSummary } from '@/lib/payment';
import { recordSalePayment } from '@/lib/saleWorkflow';
import { getInvoiceById, Invoice } from '@/lib/invoice';
import { InvoiceViewDialog } from '@/components/employeeComponents/InvoiceViewDialog';
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
@@ -62,13 +64,14 @@ import {
Check,
X,
Eye,
- Loader2,
Package,
+ Clock,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { ProductDetailModal } from '@/components/shared/ProductDetailModal';
import { getActiveCurrency } from '@/lib/currency';
+import { getUserFromToken } from '@/lib/auth';
interface CustomerMachine {
id: string;
modelName: string;
@@ -194,6 +197,20 @@ export default function ServiceContractsPage() {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('ALL');
+ // Creating/editing/deleting a contract is restricted server-side to SERVICE_HELP_DESK
+ // (see api_gateway requireServiceRole on POST/PUT/DELETE /i/service/contracts) —
+ // a technician can view this page but gets a 403 on any of those actions. Hide the
+ // buttons for them instead of letting them hit a permission error.
+ const [canManageContracts, setCanManageContracts] = useState(true);
+ useEffect(() => {
+ const user = getUserFromToken();
+ setCanManageContracts(
+ user?.role === 'ADMIN' ||
+ user?.role === 'MANAGER' ||
+ user?.employeeJob !== 'SERVICE_TECHNICIAN',
+ );
+ }, []);
+
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingContract, setEditingContract] = useState
(null);
@@ -264,24 +281,14 @@ export default function ServiceContractsPage() {
meterReading: '',
printColour: 'BLACK_WHITE' as 'BLACK_WHITE' | 'COLOUR' | 'BOTH',
description: '',
+ machineType: 'PRINTER' as 'PRINTER' | 'COMPUTER' | 'OTHER',
});
// Brand/model catalog — same "Other Machine" pattern as service ticket raising
const [brands, setBrands] = useState([]);
const [models, setModels] = useState([]);
- const [showCreateBrandModal, setShowCreateBrandModal] = useState(false);
- const [brandForm, setBrandForm] = useState({ name: '', description: '' });
- const [creatingBrandState, setCreatingBrandState] = useState(false);
- const [brandError, setBrandError] = useState(null);
- const [showCreateModelModal, setShowCreateModelModal] = useState(false);
- const [modelForm, setModelForm] = useState({
- model_no: '',
- model_name: '',
- brand_id: '',
- description: '',
- });
- const [creatingModelState, setCreatingModelState] = useState(false);
- const [modelError, setModelError] = useState(null);
+ const [showAddBrandDialog, setShowAddBrandDialog] = useState(false);
+ const [showAddModelDialog, setShowAddModelDialog] = useState(false);
const [customerIntel, setCustomerIntel] = useState(null);
const [loadingIntel, setLoadingIntel] = useState(false);
@@ -463,6 +470,15 @@ export default function ServiceContractsPage() {
toast.error('Enter the machine current meter reading — SMA usage is counted from it.');
return;
}
+ if (
+ machineCurrentMeter != null &&
+ Number(formState.startMeterReading) < machineCurrentMeter
+ ) {
+ toast.error(
+ `Starting meter cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`,
+ );
+ return;
+ }
}
if (type === 'FSMA') {
if (formState.fsmaBillingMode === 'INDIVIDUAL') {
@@ -474,6 +490,13 @@ export default function ServiceContractsPage() {
toast.error('Enter the starting B&W and colour meter readings.');
return;
}
+ const startTotal = Number(formState.startMeterBW) + Number(formState.startMeterColor);
+ if (machineCurrentMeter != null && startTotal < machineCurrentMeter) {
+ toast.error(
+ `Starting meter (B&W + colour = ${startTotal.toLocaleString()}) cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`,
+ );
+ return;
+ }
} else {
if (formState.ratePerClickCombined === '') {
toast.error('Enter the combined per-click rate.');
@@ -483,6 +506,15 @@ export default function ServiceContractsPage() {
toast.error('Enter the starting total meter reading.');
return;
}
+ if (
+ machineCurrentMeter != null &&
+ Number(formState.startMeterReading) < machineCurrentMeter
+ ) {
+ toast.error(
+ `Starting meter cannot be less than the machine's current meter reading (${machineCurrentMeter.toLocaleString()}).`,
+ );
+ return;
+ }
}
}
@@ -570,6 +602,7 @@ export default function ServiceContractsPage() {
paymentDate: payingForm.paymentDate,
referenceNumber: payingForm.referenceNumber || undefined,
remarks: payingForm.remarks || undefined,
+ paymentContext: 'SERVICE_CONTRACT_INSTALLMENT',
});
toast.success('Payment submitted for Finance approval.');
setPayingContract(null);
@@ -704,88 +737,24 @@ export default function ServiceContractsPage() {
meterReading: '',
printColour: 'BLACK_WHITE',
description: '',
+ machineType: 'PRINTER',
});
setExternalDialogOpen(true);
};
- const handleCreateBrand = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!brandForm.name.trim()) return;
- try {
- setCreatingBrandState(true);
- setBrandError(null);
- const res = await createBrand({
- name: brandForm.name.trim(),
- description: brandForm.description.trim() || undefined,
- });
- const resBrands = await getBrands().catch(() => ({ success: false, data: [] }));
- setBrands(resBrands.data || []);
-
- const createdBrand = res.data || res;
- setExternalForm((prev) => ({
- ...prev,
- brand: createdBrand.name || brandForm.name.trim(),
- }));
-
- setShowCreateBrandModal(false);
- setBrandForm({ name: '', description: '' });
- toast.success('Brand created successfully!');
- } catch (error) {
- console.error('Failed to create brand:', error);
- const msg =
- getApiErrorMessage(error) || 'Failed to create brand. Please check if it already exists.';
- setBrandError(msg);
- toast.error(msg);
- } finally {
- setCreatingBrandState(false);
+ const handleBrandCreated = async (created?: Brand) => {
+ const resBrands = await getBrands().catch(() => ({ success: false, data: [] }));
+ setBrands(resBrands.data || []);
+ if (created) {
+ setExternalForm((prev) => ({ ...prev, brand: created.name, modelName: '' }));
}
};
- const handleOpenCreateModel = () => {
- const defaultBrand = brands.find((b) => b.name === externalForm.brand);
- setModelForm({
- model_no: '',
- model_name: '',
- brand_id: defaultBrand ? defaultBrand.id : '',
- description: '',
- });
- setModelError(null);
- setShowCreateModelModal(true);
- };
-
- const handleCreateModel = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!modelForm.model_name.trim() || !modelForm.model_no.trim() || !modelForm.brand_id) {
- setModelError('Model Name, Model Number, and Brand are required.');
- return;
- }
- try {
- setCreatingModelState(true);
- setModelError(null);
- const created = await addModel({
- model_name: modelForm.model_name.trim(),
- model_no: modelForm.model_no.trim(),
- brand_id: modelForm.brand_id,
- description: modelForm.description.trim() || modelForm.model_name.trim(),
- });
- const resModels = await getAllModels({ limit: 1000 }).catch(() => ({ data: [] }));
- setModels(resModels.data || []);
-
- setExternalForm((prev) => ({
- ...prev,
- modelName: created.model_name || modelForm.model_name.trim(),
- }));
-
- setShowCreateModelModal(false);
- setModelForm({ model_no: '', model_name: '', brand_id: '', description: '' });
- toast.success('Model created successfully!');
- } catch (error) {
- console.error('Failed to create model:', error);
- const msg = getApiErrorMessage(error) || 'Failed to create model. Please try again.';
- setModelError(msg);
- toast.error(msg);
- } finally {
- setCreatingModelState(false);
+ const handleModelCreated = async (created?: { model_name: string }) => {
+ const resModels = await getAllModels({ limit: 1000 }).catch(() => ({ data: [] }));
+ setModels(resModels.data || []);
+ if (created) {
+ setExternalForm((prev) => ({ ...prev, modelName: created.model_name }));
}
};
@@ -803,7 +772,8 @@ export default function ServiceContractsPage() {
toast.error('Serial number is required.');
return;
}
- if (externalForm.meterReading === '' || Number(externalForm.meterReading) < 0) {
+ const isMetered = externalForm.machineType === 'PRINTER';
+ if (isMetered && (externalForm.meterReading === '' || Number(externalForm.meterReading) < 0)) {
toast.error('Enter the machine current meter reading.');
return;
}
@@ -815,9 +785,10 @@ export default function ServiceContractsPage() {
brand: externalForm.brand.trim(),
modelName: externalForm.modelName.trim(),
serialNumber: externalForm.serialNumber.trim(),
- meterReading: Number(externalForm.meterReading),
+ meterReading: isMetered ? Number(externalForm.meterReading) : 0,
printColour: externalForm.printColour,
description: externalForm.description.trim() || undefined,
+ machineType: externalForm.machineType,
});
toast.success(`External machine ${machine.serial_no} registered.`);
setExternalDialogOpen(false);
@@ -972,6 +943,11 @@ export default function ServiceContractsPage() {
? new Date(prod.warranty_end_date).toLocaleDateString()
: 'N/A',
activeContract: 'None',
+ // Assigned products come from a branch-unfiltered lookup, unlike the
+ // global `products` list step 6 overlays from below (which excludes
+ // external machines — they have no warehouse/branch). Set it here so
+ // an external machine's meter still shows even when step 6 can't find it.
+ meterReading: prod.meter_reading ?? undefined,
});
}
});
@@ -1039,6 +1015,27 @@ export default function ServiceContractsPage() {
}));
}, [formState.productId, products, editingContract]);
+ // A computer (or anything else non-metered) has no page count to bill by, so
+ // SMA/FSMA — both priced per click — don't apply. Only AMC's flat fee does.
+ const selectedMachineType: 'PRINTER' | 'COMPUTER' | 'OTHER' =
+ editingContract?.machine?.machineType ||
+ products.find((p) => p.id === formState.productId)?.machine_type ||
+ 'PRINTER';
+ const isMeteredMachineSelected = selectedMachineType === 'PRINTER';
+
+ useEffect(() => {
+ if (!isMeteredMachineSelected && formState.contractType !== 'AMC') {
+ setFormState((prev) => ({ ...prev, contractType: 'AMC' }));
+ }
+ }, [isMeteredMachineSelected, formState.contractType]);
+
+ // The machine's own last-known meter reading — a new SMA/FSMA contract's starting
+ // meter can never be entered lower than this (only checked at creation; once a
+ // contract exists this same number is partly made up of its own readings).
+ const machineCurrentMeter = !editingContract
+ ? (products.find((p) => p.id === formState.productId)?.meter_reading ?? null)
+ : null;
+
const selectedSerial = selectedRegistryMachine?.serialNumber;
useEffect(() => {
if (
@@ -1084,13 +1081,15 @@ export default function ServiceContractsPage() {
Manage Service Agreements (FSMA, SMA, AMC) for customer and external machines.
-
-
- Add Service Contract
-
+ {canManageContracts && (
+
+
+ Add Service Contract
+
+ )}
{/* Analytics Summary */}
@@ -1225,12 +1224,17 @@ export default function ServiceContractsPage() {
-
+
{c.machine
? `${c.machine.brand} ${c.machine.modelName}`
: product
? `${product.brand} ${product.name}`
: 'Unknown Product'}
+ {c.machine?.machineType === 'COMPUTER' && (
+
+ COMPUTER
+
+ )}
@@ -1285,12 +1289,25 @@ export default function ServiceContractsPage() {
? 'bg-amber-50 text-amber-700 border-amber-200'
: 'bg-rose-50 text-rose-700 border-rose-200';
return (
-
- {paid}
-
+ <>
+
+ {paid}
+
+ {summary.pendingApprovalCount > 0 && (
+
+
+ {summary.pendingApprovalCount === 1
+ ? '1 pending approval'
+ : `${summary.pendingApprovalCount} pending approval`}
+
+ )}
+ >
);
})()
) : (
@@ -1343,15 +1360,18 @@ export default function ServiceContractsPage() {
)}
- openBillingDialog(c)}
- title="Meter readings & monthly billing"
- className="h-7 w-7 text-slate-500 hover:text-emerald-600 hover:bg-emerald-50/50"
- >
-
-
+ {c.machine?.machineType !== 'COMPUTER' &&
+ c.machine?.machineType !== 'OTHER' && (
+ openBillingDialog(c)}
+ title="Meter readings & monthly billing"
+ className="h-7 w-7 text-slate-500 hover:text-emerald-600 hover:bg-emerald-50/50"
+ >
+
+
+ )}
{c.invoiceId &&
(contractPaymentSummaries[c.invoiceId]?.pendingBalance ?? 1) > 0 && (
)}
- handleOpenEditModal(c)}
- className="h-7 w-7 text-slate-500 hover:text-blue-600 hover:bg-blue-50/50"
- >
-
-
- {!c.invoiceId && (
+ {canManageContracts && (
+ handleOpenEditModal(c)}
+ className="h-7 w-7 text-slate-500 hover:text-blue-600 hover:bg-blue-50/50"
+ >
+
+
+ )}
+ {canManageContracts && !c.invoiceId && (
-
+ e.preventDefault()}
+ >
@@ -1637,18 +1662,30 @@ export default function ServiceContractsPage() {
Contract Type
setFormState((prev) => ({
...prev,
contractType: e.target.value as ServiceContractType,
}))
}
- className="h-10 px-3 border border-slate-200 rounded-lg text-sm bg-card focus:outline-none focus:ring-1 focus:ring-blue-500"
+ className="h-10 px-3 border border-slate-200 rounded-lg text-sm bg-card focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-slate-50 disabled:text-slate-500"
>
- FSMA (Full Service — per click)
- SMA (Service Maintenance — copy limit)
+ {isMeteredMachineSelected && (
+ FSMA (Full Service — per click)
+ )}
+ {isMeteredMachineSelected && (
+ SMA (Service Maintenance — copy limit)
+ )}
AMC (Annual Maintenance — monthly fee)
+ {!isMeteredMachineSelected && (
+
+ {selectedMachineType === 'COMPUTER' ? 'Computers' : 'This machine type'} have no
+ usage meter — only AMC (flat annual fee) applies. No meter readings will be
+ tracked for this contract.
+
+ )}
{/* Contract Value — FSMA has no upfront value, it's pure pay-per-click */}
@@ -1822,6 +1859,16 @@ export default function ServiceContractsPage() {
/>
Contract copies are counted from this baseline.
+ {machineCurrentMeter != null && (
+ <>
+ {' '}
+ Machine's current meter:{' '}
+
+ {machineCurrentMeter.toLocaleString()}
+ {' '}
+ — cannot enter less than this.
+ >
+ )}
@@ -1988,6 +2035,15 @@ export default function ServiceContractsPage() {
).toLocaleString()}
{' '}
(B&W + colour)
+ {machineCurrentMeter != null && (
+ <>
+ {' · '}machine's current meter:{' '}
+
+ {machineCurrentMeter.toLocaleString()}
+ {' '}
+ — total cannot be less than this
+ >
+ )}
>
) : (
@@ -2026,6 +2082,15 @@ export default function ServiceContractsPage() {
className="h-10 border-slate-200 focus-visible:ring-blue-500 font-mono"
placeholder="e.g. 85000"
/>
+ {machineCurrentMeter != null && (
+
+ Machine's current meter:{' '}
+
+ {machineCurrentMeter.toLocaleString()}
+ {' '}
+ — cannot enter less than this
+
+ )}
>
)}
@@ -2445,10 +2510,7 @@ export default function ServiceContractsPage() {
type="button"
variant="outline"
size="icon"
- onClick={() => {
- setBrandError(null);
- setShowCreateBrandModal(true);
- }}
+ onClick={() => setShowAddBrandDialog(true)}
className="h-10 w-10 shrink-0 border-slate-200 rounded-lg bg-card hover:bg-slate-50 text-slate-500"
>
@@ -2483,7 +2545,7 @@ export default function ServiceContractsPage() {
type="button"
variant="outline"
size="icon"
- onClick={handleOpenCreateModel}
+ onClick={() => setShowAddModelDialog(true)}
className="h-10 w-10 shrink-0 border-slate-200 rounded-lg bg-card hover:bg-slate-50 text-slate-500"
>
@@ -2502,38 +2564,65 @@ export default function ServiceContractsPage() {
/>
-
-
- Current Meter Reading *
-
-
- setExternalForm((prev) => ({ ...prev, meterReading: e.target.value }))
- }
- className="h-10 border-slate-200 focus-visible:ring-blue-500 font-mono"
- placeholder="e.g. 85000"
- />
-
-
-
Print Type
+
Machine Type
setExternalForm((prev) => ({
...prev,
- printColour: e.target.value as 'BLACK_WHITE' | 'COLOUR' | 'BOTH',
+ machineType: e.target.value as 'PRINTER' | 'COMPUTER' | 'OTHER',
}))
}
className="h-10 px-3 border border-slate-200 rounded-lg text-sm bg-card focus:outline-none focus:ring-1 focus:ring-blue-500"
>
- Black & White
- Colour
- Both
+ Printer / Copier
+ Computer
+ Other
+ {externalForm.machineType !== 'PRINTER' && (
+
+ No usage meter — this machine can only be enrolled in an AMC contract, no meter
+ readings.
+
+ )}
+ {externalForm.machineType === 'PRINTER' && (
+ <>
+
+
+
+ Current Meter Reading *
+
+
+ setExternalForm((prev) => ({ ...prev, meterReading: e.target.value }))
+ }
+ className="h-10 border-slate-200 focus-visible:ring-blue-500 font-mono"
+ placeholder="e.g. 85000"
+ />
+
+
+ Print Type
+
+ setExternalForm((prev) => ({
+ ...prev,
+ printColour: e.target.value as 'BLACK_WHITE' | 'COLOUR' | 'BOTH',
+ }))
+ }
+ className="h-10 px-3 border border-slate-200 rounded-lg text-sm bg-card focus:outline-none focus:ring-1 focus:ring-blue-500"
+ >
+ Black & White
+ Colour
+ Both
+
+
+ >
+ )}
Notes
- {/* CREATE BRAND MODAL */}
- {showCreateBrandModal && (
-
-
-
-
- Create Brand
-
-
- Add a new hardware brand to the repository list.
-
-
-
-
-
- )}
-
- {/* CREATE MODEL MODAL */}
- {showCreateModelModal && (
-
-
-
-
- Create Model
-
-
- Add a new model to the list and link it to a brand.
-
-
-
-
-
- )}
+
b.name === externalForm.brand)?.id}
+ onSuccess={handleModelCreated}
+ />
{/* AMC INSTALLMENT PAYMENT DIALOG */}
!open && setPayingContract(null)}>
@@ -2750,17 +2685,34 @@ export default function ServiceContractsPage() {
{payingContract?.invoiceId && contractPaymentSummaries[payingContract.invoiceId] && (
-
-
- Paid {getActiveCurrency()}{' '}
- {contractPaymentSummaries[payingContract.invoiceId].totalPaid.toFixed(2)} of{' '}
- {getActiveCurrency()}{' '}
- {contractPaymentSummaries[payingContract.invoiceId].totalAmount.toFixed(2)}
-
-
- Balance {getActiveCurrency()}{' '}
- {contractPaymentSummaries[payingContract.invoiceId].pendingBalance.toFixed(2)}
-
+
+
+
+ Paid {getActiveCurrency()}{' '}
+ {contractPaymentSummaries[payingContract.invoiceId].totalPaid.toFixed(2)} of{' '}
+ {getActiveCurrency()}{' '}
+ {contractPaymentSummaries[payingContract.invoiceId].totalAmount.toFixed(2)}
+
+
+ Balance {getActiveCurrency()}{' '}
+ {contractPaymentSummaries[payingContract.invoiceId].pendingBalance.toFixed(2)}
+
+
+ {contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount > 0 && (
+
+
+
+ {contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount === 1
+ ? '1 payment'
+ : `${contractPaymentSummaries[payingContract.invoiceId].pendingApprovalCount} payments`}{' '}
+ ({getActiveCurrency()}{' '}
+ {contractPaymentSummaries[
+ payingContract.invoiceId
+ ].pendingApprovalAmount.toFixed(2)}
+ ) awaiting Finance approval — not yet reflected in the balance above.
+
+
+ )}
)}
diff --git a/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx b/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx
index f7ac0ad4..5fa80aa4 100644
--- a/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx
+++ b/frontend/app/employee/(dashboard)/service/installation-requests/page.tsx
@@ -697,7 +697,10 @@ export default function InstallationRequestsPage() {
{/* Contract View Dialog */}
!v && setViewContract(null)}>
-
+
Contract Agreement
{viewContract && (
<>
@@ -814,7 +817,10 @@ export default function InstallationRequestsPage() {
}
}}
>
-
+
Initial Meter Readings
{/* Header */}
diff --git a/frontend/app/employee/(dashboard)/service/page.tsx b/frontend/app/employee/(dashboard)/service/page.tsx
index 7c64711a..3dbce49b 100644
--- a/frontend/app/employee/(dashboard)/service/page.tsx
+++ b/frontend/app/employee/(dashboard)/service/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getUserFromToken } from '@/lib/auth';
import { getBranches, Branch } from '@/lib/branch';
import { getCustomers, Customer, createCustomer, CreateCustomerData } from '@/lib/customer';
@@ -19,7 +19,7 @@ import SendDocumentModal from '@/components/SendDocumentModal';
import { RecordCustomerApprovalDialog } from '@/components/service/RecordCustomerApprovalDialog';
import { AddBrandDialog } from '@/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog';
import { ModelFormModal } from '@/components/ManagerDashboardComponents/productComponents/ModelFormModal';
-import { Play, UserPlus, Send } from 'lucide-react';
+import { Play, UserPlus, Send, AlertCircle } from 'lucide-react';
import { getAllSpareParts, SparePart } from '@/lib/spare-part';
import {
getServiceTickets,
@@ -66,10 +66,13 @@ import {
WarrantyInfo,
fetchServiceCashBankAccounts,
collectVisitCharge,
+ collectCompletionPayment,
type RecordCustomerDecisionMeta,
type CustomerDecisionChannel,
} from '@/lib/serviceTicket';
import { ServiceContract, getServiceContracts } from '@/lib/serviceContract';
+import { getInvoiceById } from '@/lib/invoice';
+import { getSalePaymentsForInvoice } from '@/lib/saleWorkflow';
import {
getStatusColor,
ServiceTicketHistoryPanel,
@@ -111,6 +114,7 @@ import {
Laptop,
FileText,
Activity,
+ Clock,
CheckCircle2,
AlertTriangle,
XCircle,
@@ -125,6 +129,7 @@ import {
} from 'lucide-react';
import { getActiveCurrency } from '@/lib/currency';
+import { getApiErrorMessage } from '@/lib/apiError';
interface AuthUser {
userId: string;
role: string;
@@ -224,6 +229,7 @@ export default function ServiceDashboardPage() {
// Modals & States
const [showCreateModal, setShowCreateModal] = useState(false);
+ const [createTicketError, setCreateTicketError] = useState('');
const [selectedTicket, setSelectedTicket] = useState(null);
const [showAssignModal, setShowAssignModal] = useState(false);
const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
@@ -310,11 +316,23 @@ export default function ServiceDashboardPage() {
| { kind: 'estimate'; estimateId: string; ticketNumber?: string; total?: number }
| null
>(null);
+ /**
+ * A charge that is taken, or taken and waiting on Accounts, is not collectable again.
+ *
+ * PENDING_APPROVAL has to count as taken everywhere: the customer has already handed
+ * the money over, and offering "Collect" again is how the same charge gets taken twice
+ * — once by the desk and once by the technician on site.
+ */
+ const visitChargeTakenOrPending = (t: ServiceTicket) =>
+ !!t.visitChargeCollected ||
+ t.visitChargeStatus === 'COLLECTED' ||
+ t.visitChargeStatus === 'PENDING_APPROVAL';
+
const isVisitChargeCollectionEligible = (t: ServiceTicket) =>
t.serviceContext === 'CHARGEABLE' &&
Number(t.visitChargeAmount || 0) > 0 &&
t.visitChargeMethod === 'ADDED_TO_ESTIMATE' &&
- !t.visitChargeCollected;
+ !visitChargeTakenOrPending(t);
// Collect-visit-charge-up-front modal — available any time before COMPLETED/
// CANCELLED, independent of diagnosis/assignment. Never gates either.
@@ -322,7 +340,7 @@ export default function ServiceDashboardPage() {
const canCollectVisitChargeNow = (t: ServiceTicket) =>
CHARGEABLE_VISIT_CONTEXTS.includes(t.serviceContext) &&
Number(t.visitChargeAmount || 0) > 0 &&
- !t.visitChargeCollected &&
+ !visitChargeTakenOrPending(t) &&
!['COMPLETED', 'CANCELLED'].includes(t.status);
const [collectVCModal, setCollectVCModal] = useState<{
ticketId: string;
@@ -332,6 +350,29 @@ export default function ServiceDashboardPage() {
const [collectVCPaymentMode, setCollectVCPaymentMode] = useState('');
const [collectVCAccountId, setCollectVCAccountId] = useState('');
const [collectVCSubmitting, setCollectVCSubmitting] = useState(false);
+
+ // Collect-completion-payment modal — "Not collected" at completion isn't
+ // final; this lets staff record the customer's payment later against an
+ // already-COMPLETED ticket. Amount due is fetched lazily on open, not per
+ // row, to avoid an invoice+payments fetch for every completed ticket.
+ const [collectCPModal, setCollectCPModal] = useState<{
+ ticketId: string;
+ ticketNumber: string;
+ } | null>(null);
+ const [collectCPAmountDue, setCollectCPAmountDue] = useState<{
+ total: number;
+ paid: number;
+ outstanding: number;
+ invoiceNumber?: string;
+ } | null>(null);
+ const [collectCPLoadingDue, setCollectCPLoadingDue] = useState(false);
+ const [collectCPAmount, setCollectCPAmount] = useState('');
+ const [collectCPPaymentMode, setCollectCPPaymentMode] = useState('');
+ const [collectCPAccountId, setCollectCPAccountId] = useState('');
+ const [collectCPChequeNumber, setCollectCPChequeNumber] = useState('');
+ const [collectCPChequeBank, setCollectCPChequeBank] = useState('');
+ const [collectCPChequeDate, setCollectCPChequeDate] = useState('');
+ const [collectCPSubmitting, setCollectCPSubmitting] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [associatedLeadName, setAssociatedLeadName] = useState('');
const [leadForm, setLeadForm] = useState({
@@ -456,6 +497,51 @@ export default function ServiceDashboardPage() {
}
};
+ const [collectVCChequeNumber, setCollectVCChequeNumber] = useState('');
+ const [collectVCChequeBank, setCollectVCChequeBank] = useState('');
+ const [collectVCChequeDate, setCollectVCChequeDate] = useState('');
+
+ /**
+ * The accounts that can actually receive this payment.
+ *
+ * The picker listed every cash AND bank account whatever mode was chosen, so a cash
+ * collection could be posted to a bank account — the money would sit in the wrong place
+ * on the balance sheet with nothing to flag it. The mode decides the account type, so
+ * the list follows it.
+ */
+ const accountsForMode = useCallback(
+ (mode: string) => {
+ if (mode === 'CASH') return cashBankAccounts.filter((a) => a.type === 'CASH');
+ if (mode === 'BANK_TRANSFER') return cashBankAccounts.filter((a) => a.type === 'BANK');
+ return [];
+ },
+ [cashBankAccounts],
+ );
+
+ const collectVCEligibleAccounts = useMemo(
+ () => accountsForMode(collectVCPaymentMode),
+ [accountsForMode, collectVCPaymentMode],
+ );
+ const collectCPEligibleAccounts = useMemo(
+ () => accountsForMode(collectCPPaymentMode),
+ [accountsForMode, collectCPPaymentMode],
+ );
+
+ /**
+ * Choosing a mode picks the account when there is only one it could be.
+ *
+ * Most branches run a single cash drawer and a single bank account, so the second
+ * dropdown was a mandatory click with exactly one option behind it.
+ */
+ const handleCollectVCModeChange = (mode: string) => {
+ setCollectVCPaymentMode(mode);
+ setCollectVCChequeNumber('');
+ setCollectVCChequeBank('');
+ setCollectVCChequeDate('');
+ const eligible = accountsForMode(mode);
+ setCollectVCAccountId(eligible.length === 1 ? eligible[0].id : '');
+ };
+
const [assignForm, setAssignForm] = useState({
technicianId: '',
});
@@ -471,6 +557,7 @@ export default function ServiceDashboardPage() {
visitChargeCollected: boolean;
visitChargePaymentMode: string;
visitChargeAccountId: string;
+ visitChargeChequeNumber?: string;
transportChargeAmount: number;
discountAmount: number;
technicianNoteToFinance: string;
@@ -480,6 +567,7 @@ export default function ServiceDashboardPage() {
customPartName: string;
customPartBrand: string;
customPartDescription: string;
+ customPartCost: number;
mpn: string;
partName: string;
quantity: number;
@@ -497,6 +585,7 @@ export default function ServiceDashboardPage() {
visitChargeCollected: true,
visitChargePaymentMode: '',
visitChargeAccountId: '',
+ visitChargeChequeNumber: '',
transportChargeAmount: 0,
discountAmount: 0,
technicianNoteToFinance: '',
@@ -513,6 +602,38 @@ export default function ServiceDashboardPage() {
const [completionNotes, setCompletionNotes] = useState('');
+ /**
+ * Payment the technician takes at the door when they close the job.
+ *
+ * Optional on purpose — a customer who pays later still closes the ticket, the invoice
+ * just stays outstanding for Accounts to chase. But when the money IS handed over on
+ * site, capturing the mode and the destination account here is the only chance to record
+ * it while the technician still knows; otherwise the cash is invisible until somebody in
+ * Accounts keys it in from memory.
+ */
+ /**
+ * What the customer actually owes at completion.
+ *
+ * NOT the estimate total. Approving within validity waives the labour line, so a job
+ * quoted at 550 can be a 150 bill — prefilling the estimate figure would have the
+ * technician collect 400 too much. The invoice carries the post-waiver amount, and any
+ * payment already taken (an up-front visit charge, a part payment) comes off it too.
+ */
+ const [amountDue, setAmountDue] = useState<{
+ total: number;
+ paid: number;
+ outstanding: number;
+ invoiceNumber?: string;
+ } | null>(null);
+ const [loadingDue, setLoadingDue] = useState(false);
+
+ const [collectAmount, setCollectAmount] = useState('');
+ const [collectMode, setCollectMode] = useState('');
+ const [collectAccountId, setCollectAccountId] = useState('');
+ const [collectChequeNo, setCollectChequeNo] = useState('');
+ const [collectChequeBank, setCollectChequeBank] = useState('');
+ const [collectChequeDate, setCollectChequeDate] = useState('');
+
// Intel view states
const [selectedIntelCustomer, setSelectedIntelCustomer] = useState('');
const [intelData, setIntelData] = useState(null);
@@ -668,21 +789,29 @@ export default function ServiceDashboardPage() {
: undefined,
issueDescription: newTicket.issueDescription.trim(),
meterReadingAtCreation:
- !isOtherMachine && newTicket.machineType === 'PRINTER' && meterReadingInput !== ''
+ newTicket.machineType === 'PRINTER' && meterReadingInput !== ''
? Number(meterReadingInput)
: undefined,
visitChargeAmount:
newTicket.visitChargeAmount !== '' ? Number(newTicket.visitChargeAmount) : undefined,
};
+ setCreateTicketError('');
await createServiceTicket(payload);
toast.success('Service ticket created — confirmation email sent to the customer.');
setShowCreateModal(false);
resetTicketForm();
await fetchInitialData();
} catch (error) {
- console.error('Failed to create ticket:', error);
- toast.error('Error creating service ticket. Please verify inputs and connection.');
+ // Expected, user-facing validation failure (e.g. duplicate open ticket) —
+ // shown via the inline banner below, not console.error: that flags a real
+ // bug to Next's dev overlay, which this is not.
+ setCreateTicketError(
+ getApiErrorMessage(
+ error,
+ 'Error creating service ticket. Please verify inputs and connection.',
+ ),
+ );
} finally {
setSubmitting(false);
}
@@ -857,11 +986,18 @@ export default function ServiceDashboardPage() {
}
try {
setCollectVCSubmitting(true);
- await collectVisitCharge(collectVCModal.ticketId, collectVCPaymentMode, collectVCAccountId);
- toastSuccess('Visit charge collected.');
+ await collectVisitCharge(collectVCModal.ticketId, collectVCPaymentMode, collectVCAccountId, {
+ chequeNumber: collectVCChequeNumber.trim() || undefined,
+ chequeBankName: collectVCChequeBank.trim() || undefined,
+ chequeDate: collectVCChequeDate || undefined,
+ });
+ toastSuccess('Visit charge sent to Accounts for approval.');
setCollectVCModal(null);
setCollectVCPaymentMode('');
setCollectVCAccountId('');
+ setCollectVCChequeNumber('');
+ setCollectVCChequeBank('');
+ setCollectVCChequeDate('');
await fetchInitialData();
} catch (error) {
console.error('Failed to collect visit charge:', error);
@@ -871,26 +1007,83 @@ export default function ServiceDashboardPage() {
}
};
+ const openCollectCompletionPayment = async (ticket: ServiceTicket) => {
+ setCollectCPModal({
+ ticketId: ticket.id,
+ ticketNumber: ticket.ticketNumber,
+ });
+ setCollectCPPaymentMode('');
+ setCollectCPAccountId('');
+ setCollectCPChequeNumber('');
+ setCollectCPChequeBank('');
+ setCollectCPChequeDate('');
+ setCollectCPAmountDue(null);
+ setCollectCPAmount('');
+ loadCashBankAccounts(ticket.branchId);
+ if (!ticket.serviceQuotationId) return;
+ setCollectCPLoadingDue(true);
+ try {
+ const [inv, payments] = await Promise.all([
+ getInvoiceById(ticket.serviceQuotationId),
+ getSalePaymentsForInvoice(ticket.serviceQuotationId).catch(() => []),
+ ]);
+ const total = Number(inv?.totalAmount) || 0;
+ // Pending counts as paid: that money is already with Accounts awaiting
+ // approval, and collecting it twice is the failure to avoid.
+ const paid = (payments || [])
+ .filter((p) => p.status === 'APPROVED' || p.status === 'PENDING')
+ .reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
+ const outstanding = Math.max(0, total - paid);
+ setCollectCPAmountDue({ total, paid, outstanding, invoiceNumber: inv?.invoiceNumber });
+ setCollectCPAmount(outstanding > 0 ? String(outstanding) : '');
+ } catch {
+ setCollectCPAmountDue(null);
+ } finally {
+ setCollectCPLoadingDue(false);
+ }
+ };
+
+ const handleCollectCompletionPayment = async () => {
+ if (!collectCPModal) return;
+ if (!collectCPPaymentMode || (collectCPPaymentMode !== 'CHEQUE' && !collectCPAccountId)) {
+ toast.error('Select a payment mode (and account, unless paying by cheque).');
+ return;
+ }
+ if (!(Number(collectCPAmount) > 0)) {
+ toast.error('Enter an amount greater than 0.');
+ return;
+ }
+ try {
+ setCollectCPSubmitting(true);
+ await collectCompletionPayment(collectCPModal.ticketId, {
+ amount: Number(collectCPAmount),
+ paymentMode: collectCPPaymentMode,
+ accountId: collectCPAccountId || undefined,
+ chequeNumber: collectCPChequeNumber.trim() || undefined,
+ chequeBankName: collectCPChequeBank.trim() || undefined,
+ chequeDate: collectCPChequeDate || undefined,
+ });
+ toastSuccess('Payment sent to Accounts for approval.');
+ setCollectCPModal(null);
+ await fetchInitialData();
+ } catch (error) {
+ console.error('Failed to collect completion payment:', error);
+ toastError('Failed to collect payment.');
+ } finally {
+ setCollectCPSubmitting(false);
+ }
+ };
+
const handleDiagnose = async (e?: React.FormEvent, confirmed = false) => {
e?.preventDefault();
if (!selectedTicket) return;
- // Discount applies to the whole estimate: parts + labour + transport +
- // visit charge (when added to the estimate). It cannot exceed that total.
- const partsTotal = diagnosisForm.items.reduce(
- (sum, item) => sum + (item.isFree ? 0 : (item.quantity || 1) * (item.unitPrice || 0)),
- 0,
- );
- const estimateTotalBeforeDiscount =
- partsTotal +
- (Number(diagnosisForm.labourCost) || 0) +
- (Number(diagnosisForm.transportChargeAmount) || 0) +
- (diagnosisForm.visitChargeMethod === 'ADDED_TO_ESTIMATE'
- ? Number(diagnosisForm.visitChargeAmount) || 0
- : 0);
- if (Number(diagnosisForm.discountAmount || 0) > estimateTotalBeforeDiscount) {
+ // Discount is a discount on labour/service charge only — it cannot
+ // exceed the labour cost, regardless of parts/transport/visit charge.
+ const labourCost = Number(diagnosisForm.labourCost) || 0;
+ if (Number(diagnosisForm.discountAmount || 0) > labourCost) {
toast.error(
- `Discount of ${getActiveCurrency()} ${diagnosisForm.discountAmount} exceeds the total estimate amount of ${getActiveCurrency()} ${estimateTotalBeforeDiscount.toFixed(2)}.`,
+ `Discount of ${getActiveCurrency()} ${diagnosisForm.discountAmount} exceeds the labour cost of ${getActiveCurrency()} ${labourCost.toFixed(2)}.`,
);
return;
}
@@ -943,6 +1136,7 @@ export default function ServiceDashboardPage() {
customPartName: it.customPartName || undefined,
customPartBrand: it.customPartBrand || undefined,
customPartDescription: it.customPartDescription || undefined,
+ customPartCost: it.itemSource === 'CUSTOM' ? Number(it.customPartCost) || 0 : undefined,
mpn: it.mpn || undefined,
partName: it.partName,
quantity: Number(it.quantity) || 1,
@@ -978,6 +1172,10 @@ export default function ServiceDashboardPage() {
visitChargeAccountId: isSeparateVisitChargeCollection
? diagnosisForm.visitChargeAccountId
: undefined,
+ visitChargeChequeNumber:
+ isSeparateVisitChargeCollection && diagnosisForm.visitChargePaymentMode === 'CHEQUE'
+ ? diagnosisForm.visitChargeChequeNumber
+ : undefined,
transportChargeAmount: Number(diagnosisForm.transportChargeAmount) || 0,
discountAmount: Number(diagnosisForm.discountAmount) || 0,
technicianNoteToFinance: diagnosisForm.technicianNoteToFinance || null,
@@ -987,6 +1185,7 @@ export default function ServiceDashboardPage() {
customPartName: it.customPartName || undefined,
customPartBrand: it.customPartBrand || undefined,
customPartDescription: it.customPartDescription || undefined,
+ customPartCost: it.itemSource === 'CUSTOM' ? Number(it.customPartCost) || 0 : undefined,
mpn: it.mpn || undefined,
partName: it.partName,
quantity: Number(it.quantity) || 1,
@@ -1069,6 +1268,41 @@ export default function ServiceDashboardPage() {
// setConfirmOpen(true);
// };
+ /**
+ * Loads the outstanding balance when the completion form opens, and prefills the amount
+ * so the technician confirms a figure rather than inventing one.
+ */
+ const loadAmountDue = useCallback(async (ticket: ServiceTicket | null) => {
+ if (!ticket?.serviceQuotationId) {
+ setAmountDue(null);
+ return;
+ }
+ setLoadingDue(true);
+ try {
+ const [inv, payments] = await Promise.all([
+ getInvoiceById(ticket.serviceQuotationId),
+ getSalePaymentsForInvoice(ticket.serviceQuotationId).catch(() => []),
+ ]);
+ const total = Number(inv?.totalAmount) || 0;
+ // Pending counts as paid for this purpose: that money is already with Accounts
+ // awaiting approval, and collecting it twice is the failure to avoid.
+ const paid = (payments || [])
+ .filter((p) => p.status === 'APPROVED' || p.status === 'PENDING')
+ .reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
+ const outstanding = Math.max(0, total - paid);
+ setAmountDue({ total, paid, outstanding, invoiceNumber: inv?.invoiceNumber });
+ setCollectAmount(outstanding > 0 ? String(outstanding) : '');
+ } catch {
+ setAmountDue(null);
+ } finally {
+ setLoadingDue(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (showCompleteModal) loadAmountDue(selectedTicket);
+ }, [showCompleteModal, selectedTicket, loadAmountDue]);
+
const handleCompleteService = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTicket) return;
@@ -1085,6 +1319,12 @@ export default function ServiceDashboardPage() {
technicianRemarks: completeForm.technicianRemarks || undefined,
customerSignature: completeForm.customerSignature || 'Customer Signed',
technicianSignature: completeForm.technicianSignature || 'Technician Signed',
+ collectedAmount: collectMode && collectAmount ? Number(collectAmount) : undefined,
+ paymentMode: collectMode || undefined,
+ paymentAccountId: collectMode && collectMode !== 'CHEQUE' ? collectAccountId : undefined,
+ chequeNumber: collectMode === 'CHEQUE' ? collectChequeNo : undefined,
+ chequeBankName: collectMode === 'CHEQUE' ? collectChequeBank : undefined,
+ chequeDate: collectMode === 'CHEQUE' ? collectChequeDate : undefined,
});
setShowCompleteModal(false);
setCompleteForm({
@@ -1097,8 +1337,18 @@ export default function ServiceDashboardPage() {
technicianSignature: 'Technician Signed',
});
setCompletionNotes('');
+ setCollectAmount('');
+ setCollectMode('');
+ setCollectAccountId('');
+ setCollectChequeNo('');
+ setCollectChequeBank('');
+ setCollectChequeDate('');
await fetchInitialData();
- toastSuccess('Service job completed successfully!');
+ toastSuccess(
+ collectMode && collectAmount
+ ? `Job completed. ${getActiveCurrency()} ${collectAmount} sent to Accounts for approval.`
+ : 'Service job completed successfully!',
+ );
} catch (error) {
console.error('Failed to complete ticket:', error);
toastError('Failed to complete service job.');
@@ -1154,20 +1404,27 @@ export default function ServiceDashboardPage() {
totalLabourCost: Number(data.history?.totalLabourSpend) || 0,
totalLifetimeCost: Number(data.history?.totalLifetimeCost) || 0,
currentMeterReading: data.currentMeterReading ?? null,
- visitLogs: (data.tickets || []).map((t: ServiceTicket) => {
- let ticketCost = 0;
- t.items?.forEach((item: ServiceTicketItem) => {
- ticketCost += Number(item.totalPrice) || 0;
- });
- return {
- ticketNumber: t.ticketNumber,
- serviceContext: t.serviceContext,
- status: t.status,
- date: t.completedAt || t.created_at,
- meterReading: t.meterReadingAtService || t.meterReadingAtCreation || 0,
- cost: ticketCost,
- };
- }),
+ visitLogs: (data.tickets || []).map(
+ (t: ServiceTicket & { estimateTotalCost?: number | null }) => {
+ // The latest estimate's total already covers parts + labour +
+ // visit/transport charge - discount. Fall back to summing billed
+ // items only for older tickets that predate estimates.
+ let cost = t.estimateTotalCost ?? 0;
+ if (t.estimateTotalCost == null) {
+ t.items?.forEach((item: ServiceTicketItem) => {
+ cost += Number(item.totalPrice) || 0;
+ });
+ }
+ return {
+ ticketNumber: t.ticketNumber,
+ serviceContext: t.serviceContext,
+ status: t.status,
+ date: t.completedAt || t.created_at,
+ meterReading: t.meterReadingAtService || t.meterReadingAtCreation || 0,
+ cost,
+ };
+ },
+ ),
};
const mappedYields: ConsumableYieldUI[] = (data.yields || []).map(
@@ -1646,6 +1903,7 @@ export default function ServiceDashboardPage() {
};
const resetTicketForm = () => {
+ setCreateTicketError('');
setNewTicket({
customerId: '',
leadId: '',
@@ -1700,6 +1958,7 @@ export default function ServiceDashboardPage() {
customPartName: '',
customPartBrand: '',
customPartDescription: '',
+ customPartCost: 0,
mpn: '',
partName: '',
quantity: 1,
@@ -1817,7 +2076,10 @@ export default function ServiceDashboardPage() {
{/* Action buttons based on jobs — manager has full authority in the branch */}
{(isHelpDesk || isManagerOrAdmin) && (
setShowCreateModal(true)}
+ onClick={() => {
+ setCreateTicketError('');
+ setShowCreateModal(true);
+ }}
className="bg-primary hover:bg-primary/95 text-white font-bold rounded-xl shadow-sm gap-2"
>
Create Service Ticket
@@ -2027,7 +2289,13 @@ export default function ServiceDashboardPage() {
)}
- {(ticket.status === 'QUOTED' || ticket.status === 'CUSTOMER_APPROVED') && (
+ {/* FINANCE_APPROVED is the state a Finance approval now leaves the
+ ticket in; QUOTED is kept so tickets approved before that fix
+ still offer the share. */}
+ {(ticket.status === 'FINANCE_APPROVED' ||
+ ticket.status === 'FINANCE_APPROVED_2' ||
+ ticket.status === 'QUOTED' ||
+ ticket.status === 'CUSTOMER_APPROVED') && (
- Collect Visit Charge
+ {ticket.visitChargeStatus === 'REJECTED'
+ ? 'Collect Visit Charge Again'
+ : 'Collect Visit Charge'}
)}
+ {/* "Not collected" at completion isn't final — the customer can
+ still pay later. Amount due is fetched lazily when this opens,
+ not per row, so a completed-tickets list doesn't fire an
+ invoice+payments lookup for every row. */}
+ {(isHelpDesk || isManagerOrAdmin || isTechnician) &&
+ ticket.status === 'COMPLETED' &&
+ ticket.serviceQuotationId && (
+ openCollectCompletionPayment(ticket)}
+ >
+
+ Collect Payment
+
+ )}
+
+ {/* What happened to the charge, once someone has taken it. The
+ desk used to get no feedback at all after clicking Collect —
+ the button simply vanished, which is indistinguishable from
+ the action having failed. */}
+ {ticket.visitChargeStatus === 'PENDING_APPROVAL' && (
+
+
+ Awaiting Accounts Approval
+
+ )}
+ {(ticket.visitChargeStatus === 'COLLECTED' ||
+ (!ticket.visitChargeStatus && ticket.visitChargeCollected)) && (
+
+
+ Visit Charge Collected
+
+ )}
+ {ticket.visitChargeStatus === 'REJECTED' &&
+ ticket.visitChargeRejectionReason && (
+
+
+ Accounts Rejected
+
+ )}
+
{/* Recording that the customer accepted is limited to the assigned
technician and branch manager/admin — they're the ones actually
present with the customer, not Help Desk. */}
@@ -2220,7 +2550,7 @@ export default function ServiceDashboardPage() {
rootCause: '',
meterReading: 0,
labourCost: 0,
- visitChargeAmount: ticket.visitChargeAmount || 0,
+ visitChargeAmount: Number(ticket.visitChargeAmount) || 0,
visitChargeMethod: 'ADDED_TO_ESTIMATE',
visitChargeCollected: true,
visitChargePaymentMode: '',
@@ -2280,7 +2610,7 @@ export default function ServiceDashboardPage() {
rootCause: ticket.rootCause || '',
meterReading: ticket.meterReadingAtService || 0,
labourCost: laborItem ? Number(laborItem.unitPrice) : 0,
- visitChargeAmount: ticket.visitChargeAmount || 0,
+ visitChargeAmount: Number(ticket.visitChargeAmount) || 0,
visitChargeMethod:
(ticket.visitChargeMethod as
| 'ADDED_TO_ESTIMATE'
@@ -2297,6 +2627,7 @@ export default function ServiceDashboardPage() {
customPartName: it.customPartName || '',
customPartBrand: it.customPartBrand || '',
customPartDescription: it.customPartDescription || '',
+ customPartCost: it.unitCost || 0,
mpn: it.mpn || '',
partName: it.partName || '',
quantity: it.quantity || 1,
@@ -2442,6 +2773,12 @@ export default function ServiceDashboardPage() {
+ {machine.meterReading != null && (
+
+ Last Reading:{' '}
+
+ {machine.meterReading.toLocaleString()}
+
+
+ )}
))
@@ -2750,6 +3095,14 @@ export default function ServiceDashboardPage() {
)}
+ {machine.meterReading != null && (
+
+ Last Reading:{' '}
+
+ {machine.meterReading.toLocaleString()}
+
+
+ )}
))
@@ -2843,6 +3196,14 @@ export default function ServiceDashboardPage() {
)
)}
+ {machine.meterReading != null && (
+
+ Last Reading:{' '}
+
+ {machine.meterReading.toLocaleString()}
+
+
+ )}
))
@@ -3211,68 +3572,68 @@ export default function ServiceDashboardPage() {
{/* METER READING — copies can expire a warranty before time does.
- Printer machines only; computer / other have no meter. */}
- {newTicket.machineType === 'PRINTER' &&
- (selectedMachine.type === 'SALE' ||
- selectedMachine.type === 'LEASE') && (
-
-
- Current Meter Reading (Total Copies)
- {machineContextData?.warrantyInfo?.copyLimit != null && ' *'}
-
-
setMeterReadingInput(e.target.value)}
- // Re-check warranty/coverage only once the full number is
- // entered — on blur, or on Enter — not per digit.
- onBlur={() => {
- if (selectedMachine?.serialNumber) {
- fetchMachineContext(
- selectedMachine.serialNumber,
- meterReadingInput !== ''
- ? Number(meterReadingInput)
- : undefined,
- );
- }
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- (e.target as HTMLInputElement).blur();
- }
- }}
- className="h-9 text-xs bg-white border-amber-200 rounded-xl focus-visible:ring-amber-500 font-mono"
- />
- {machineContextData?.warrantyInfo && (
-
- {machineContextData.warrantyInfo.isUnderWarranty
- ? `Under warranty${
- machineContextData.warrantyInfo.copiesRemaining != null
- ? ` — ${machineContextData.warrantyInfo.copiesRemaining.toLocaleString()} copies remaining`
- : ''
- }${
- machineContextData.warrantyInfo.warrantyEndDate
- ? ` (until ${new Date(machineContextData.warrantyInfo.warrantyEndDate).toLocaleDateString()})`
- : ''
- }`
- : `Warranty expired${
- machineContextData.warrantyInfo.expiredBy
- ? ` — limit hit: ${machineContextData.warrantyInfo.expiredBy}`
- : ''
- }. Service will be chargeable.`}
-
- )}
-
- )}
+ Printer machines only; computer / other have no meter.
+ Asked regardless of ownership (SALE/LEASE/RENT/EXTERNAL) so
+ meterReadingAtCreation is always captured for the ticket. */}
+ {newTicket.machineType === 'PRINTER' && (
+
+
+ Current Meter Reading (Total Copies)
+ {machineContextData?.warrantyInfo?.copyLimit != null && ' *'}
+
+
setMeterReadingInput(e.target.value)}
+ // Re-check warranty/coverage only once the full number is
+ // entered — on blur, or on Enter — not per digit.
+ onBlur={() => {
+ if (selectedMachine?.serialNumber) {
+ fetchMachineContext(
+ selectedMachine.serialNumber,
+ meterReadingInput !== ''
+ ? Number(meterReadingInput)
+ : undefined,
+ );
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ (e.target as HTMLInputElement).blur();
+ }
+ }}
+ className="h-9 text-xs bg-white border-amber-200 rounded-xl focus-visible:ring-amber-500 font-mono"
+ />
+ {machineContextData?.warrantyInfo && (
+
+ {machineContextData.warrantyInfo.isUnderWarranty
+ ? `Under warranty${
+ machineContextData.warrantyInfo.copiesRemaining != null
+ ? ` — ${machineContextData.warrantyInfo.copiesRemaining.toLocaleString()} copies remaining`
+ : ''
+ }${
+ machineContextData.warrantyInfo.warrantyEndDate
+ ? ` (until ${new Date(machineContextData.warrantyInfo.warrantyEndDate).toLocaleDateString()})`
+ : ''
+ }`
+ : `Warranty expired${
+ machineContextData.warrantyInfo.expiredBy
+ ? ` — limit hit: ${machineContextData.warrantyInfo.expiredBy}`
+ : ''
+ }. Service will be chargeable.`}
+
+ )}
+
+ )}
{machineContextData?.contract && (
@@ -3409,6 +3770,25 @@ export default function ServiceDashboardPage() {
)}
+ {/* METER READING — for an external/new-lead machine there's no catalogue
+ record to pull a prior reading from, so ask for it directly. */}
+ {(creationPath === 'new' || isOtherMachine) &&
+ newTicket.machineType === 'PRINTER' && (
+
+
+ Current Meter Reading (Total Copies)
+
+ setMeterReadingInput(e.target.value)}
+ className="h-9 text-xs bg-white border-slate-200 rounded-xl focus-visible:ring-primary font-mono"
+ />
+
+ )}
+
{/* Information Banner */}
{(() => {
let bannerClass = '';
@@ -4087,7 +4467,7 @@ export default function ServiceDashboardPage() {
{item.itemSource === 'CUSTOM' && (
-
+
Brand
@@ -4125,11 +4505,33 @@ export default function ServiceDashboardPage() {
className="h-9 text-xs bg-white border-slate-200 rounded-lg"
/>
+
+
+ Internal Cost (what we paid)
+
+
+ updateDiagnosisItem(
+ idx,
+ 'customPartCost',
+ parseFloat(e.target.value) || 0,
+ )
+ }
+ className="h-9 text-xs bg-white border-slate-200 rounded-lg"
+ />
+
Brand and Model Name are pre-filled from the machine on this ticket —
- edit if the part differs.
+ edit if the part differs. Internal cost is never shown to the customer —
+ it's tracked so we know what off-catalog parts actually cost us,
+ and flags the branch manager to consider stocking it via RFQ.
)}
@@ -4257,9 +4659,30 @@ export default function ServiceDashboardPage() {
+ {/* Already taken by the service desk (approved, or sitting with
+ Accounts): the technician must not be offered the same charge
+ again on site, or the customer pays it twice. */}
+ {!travelCovered &&
+ diagnosisForm.visitChargeMethod === 'SEPARATE' &&
+ (diagnosisForm.visitChargeAmount || 0) > 0 &&
+ visitChargeTakenOrPending(selectedTicket) && (
+
+
+
+ {selectedTicket.visitChargeStatus === 'PENDING_APPROVAL'
+ ? 'Visit charge already collected by the service desk — awaiting Accounts approval. Do not collect it again.'
+ : 'Visit charge already collected. Do not collect it again.'}
+ {selectedTicket.visitChargeCollectedByName
+ ? ` Collected by ${selectedTicket.visitChargeCollectedByName}.`
+ : ''}
+
+
+ )}
+
{!travelCovered &&
diagnosisForm.visitChargeMethod === 'SEPARATE' &&
- (diagnosisForm.visitChargeAmount || 0) > 0 && (
+ (diagnosisForm.visitChargeAmount || 0) > 0 &&
+ !visitChargeTakenOrPending(selectedTicket) && (
<>
- Cash collected on-site — post {getActiveCurrency()}{' '}
+ Cash collected on-site — send {getActiveCurrency()}{' '}
{Number(diagnosisForm.visitChargeAmount || 0).toFixed(2)} to
- accounts now
+ Accounts for approval
@@ -4299,15 +4722,32 @@ export default function ServiceDashboardPage() {
visitChargeAccountId: '',
})
}
- className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary"
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400"
>
Select mode...
Cash
Bank Transfer
Cheque
- Credit Card
+ {diagnosisForm.visitChargePaymentMode === 'CHEQUE' && (
+
+
+ Cheque No. *
+
+
+ setDiagnosisForm({
+ ...diagnosisForm,
+ visitChargeChequeNumber: e.target.value,
+ })
+ }
+ placeholder="e.g. CHQ-004512"
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+ )}
{diagnosisForm.visitChargePaymentMode &&
diagnosisForm.visitChargePaymentMode !== 'CHEQUE' && (
@@ -4322,12 +4762,14 @@ export default function ServiceDashboardPage() {
visitChargeAccountId: e.target.value,
})
}
- className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary"
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400"
>
Select account...
- {cashBankAccounts.map((a) => (
+ {accountsForMode(
+ diagnosisForm.visitChargePaymentMode,
+ ).map((a) => (
- {a.name} ({a.type})
+ {a.name}
))}
@@ -4379,17 +4821,21 @@ export default function ServiceDashboardPage() {
+ onChange={(e) => {
+ const labourCost = Number(diagnosisForm.labourCost) || 0;
+ const entered = parseFloat(e.target.value) || 0;
setDiagnosisForm({
...diagnosisForm,
- discountAmount: parseFloat(e.target.value) || 0,
- })
- }
+ discountAmount: Math.min(entered, labourCost),
+ });
+ }}
className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl"
/>
- Applied on the total estimate amount.
+ Cannot exceed the labour cost ({getActiveCurrency()}{' '}
+ {(Number(diagnosisForm.labourCost) || 0).toFixed(2)}).
@@ -4403,19 +4849,28 @@ export default function ServiceDashboardPage() {
{getActiveCurrency()}
{(() => {
+ // Every term is coerced before it is added. These fields are
+ // TYPED as number but arrive as strings: the ticket comes from
+ // the API, where a Postgres numeric column serialises as
+ // "150.00", and prefilling the form put that string straight
+ // into state. `+` then concatenated instead of adding, so
+ // labour 100 and a 150.00 visit charge displayed as 100150.00 —
+ // a number the technician would read out to the customer.
+ const num = (v: unknown) => Number(v) || 0;
const partsTotal = diagnosisForm.items.reduce(
(sum, item) =>
- sum + (item.isFree ? 0 : item.quantity * item.unitPrice),
+ sum +
+ (item.isFree ? 0 : num(item.quantity) * num(item.unitPrice)),
0,
);
- const laborTotal = diagnosisForm.labourCost || 0;
+ const laborTotal = num(diagnosisForm.labourCost);
const transportTotal = travelCovered
? 0
- : diagnosisForm.transportChargeAmount || 0;
+ : num(diagnosisForm.transportChargeAmount);
const visitChargeToAdd =
!travelCovered &&
diagnosisForm.visitChargeMethod === 'ADDED_TO_ESTIMATE'
- ? diagnosisForm.visitChargeAmount || 0
+ ? num(diagnosisForm.visitChargeAmount)
: 0;
const totalEstimate = Math.max(
0,
@@ -4423,7 +4878,7 @@ export default function ServiceDashboardPage() {
laborTotal +
transportTotal +
visitChargeToAdd -
- (diagnosisForm.discountAmount || 0),
+ num(diagnosisForm.discountAmount),
);
return totalEstimate.toFixed(2);
})()}
@@ -4631,6 +5086,144 @@ export default function ServiceDashboardPage() {
className="h-9 text-xs bg-slate-50 border-slate-200 rounded-xl"
/>
+
+ {/* Payment taken at the door. Orange, matching the other money sections on
+ this page, and gated behind choosing a mode so a technician who took
+ nothing is not asked to fill anything in. */}
+
+
+
+ Payment collected on site (optional)
+
+ {loadingDue ? (
+
+ Loading balance…
+
+ ) : amountDue ? (
+
+ Due now: {getActiveCurrency()} {amountDue.outstanding.toFixed(2)}
+ {amountDue.invoiceNumber ? (
+
+ ({amountDue.invoiceNumber})
+
+ ) : null}
+
+ ) : null}
+
+
+ {/* The estimate and the bill can differ — labour is waived when the
+ customer approves within validity — so the figure is spelled out
+ rather than left for the technician to reconcile in their head. */}
+ {amountDue && amountDue.paid > 0 && (
+
+ Invoice {getActiveCurrency()} {amountDue.total.toFixed(2)} · already collected{' '}
+ {getActiveCurrency()} {amountDue.paid.toFixed(2)}
+
+ )}
+ {amountDue && amountDue.outstanding === 0 && (
+
+ Nothing outstanding — this invoice is already settled.
+
+ )}
+
+
+
+ Payment Mode
+
+ {
+ const mode = e.target.value;
+ setCollectMode(mode);
+ setCollectChequeNo('');
+ setCollectChequeBank('');
+ setCollectChequeDate('');
+ const eligible = accountsForMode(mode);
+ setCollectAccountId(eligible.length === 1 ? eligible[0].id : '');
+ if (mode && selectedTicket?.branchId) {
+ loadCashBankAccounts(selectedTicket.branchId);
+ }
+ }}
+ className="w-full h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ >
+ Not collected
+ Cash
+ Bank Transfer
+ Cheque
+
+
+
+ {collectMode && (
+
+
+ Amount ({getActiveCurrency()})
+
+
setCollectAmount(e.target.value)}
+ placeholder={amountDue ? amountDue.outstanding.toFixed(2) : '0.00'}
+ className="w-full h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+ {amountDue && Number(collectAmount) > amountDue.outstanding + 0.01 && (
+
+ More than the {getActiveCurrency()} {amountDue.outstanding.toFixed(2)}{' '}
+ outstanding.
+
+ )}
+
+ )}
+
+ {collectMode && collectMode !== 'CHEQUE' && (
+
+
+ {collectMode === 'CASH' ? 'Cash Account' : 'Bank Account'}
+
+ ({
+ value: a.id,
+ label: a.name,
+ }))}
+ value={collectAccountId}
+ onValueChange={(val) => setCollectAccountId(val)}
+ placeholder="Select account..."
+ className="w-full h-9 px-3 text-xs bg-white border-orange-200 rounded-xl text-orange-900 font-semibold"
+ />
+
+ )}
+
+
+ {collectMode === 'CHEQUE' && (
+
+ setCollectChequeNo(e.target.value)}
+ placeholder="Cheque No. *"
+ className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+ setCollectChequeBank(e.target.value)}
+ placeholder="Bank"
+ className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+ setCollectChequeDate(e.target.value)}
+ className="h-9 px-3 text-xs bg-white border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+ )}
+
+ {collectMode && (
+
+ Goes to Accounts for approval. The money posts to the account selected here
+ once they approve it — nothing reaches the cashbook before that.
+
+ )}
+
Total
+
+ Internal Cost
+
@@ -5001,11 +5597,21 @@ export default function ServiceDashboardPage() {
`${getActiveCurrency()} ${item.totalPrice.toLocaleString(undefined, { minimumFractionDigits: 2 })}`
)}
+
+ {getActiveCurrency()}{' '}
+ {Number(item.totalCost || 0).toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ })}
+
))}
+
+ Internal Cost is what we actually spent — staff view only, never shown to
+ the customer.
+
)}
@@ -6575,36 +7181,94 @@ export default function ServiceDashboardPage() {
for later (on-site or on the completion bill) — collecting it here does not affect
technician assignment or diagnosis.
+
+ Recording this sends the collection to Accounts for approval. Nothing is posted to the
+ cashbook until they approve it, and the technician will no longer be offered this
+ charge on site.
+
-
{
- setCollectVCPaymentMode(e.target.value);
- setCollectVCAccountId('');
- }}
- className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl"
- >
- Select mode...
- Cash
- Bank Transfer
- Cheque
- Credit Card
-
- {collectVCPaymentMode && collectVCPaymentMode !== 'CHEQUE' && (
+
+
+ Payment Mode
+
setCollectVCAccountId(e.target.value)}
- className="w-full h-9 px-3 text-xs bg-slate-50 border border-slate-200 rounded-xl"
+ value={collectVCPaymentMode}
+ onChange={(e) => handleCollectVCModeChange(e.target.value)}
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400"
>
- Select account...
- {cashBankAccounts.map((a) => (
-
- {a.name} ({a.type})
-
- ))}
+ Select mode...
+ Cash
+ Bank Transfer
+ Cheque
+
+ {collectVCPaymentMode && collectVCPaymentMode !== 'CHEQUE' && (
+
+
+ {collectVCPaymentMode === 'CASH' ? 'Cash Account' : 'Bank Account'}
+
+ setCollectVCAccountId(e.target.value)}
+ disabled={collectVCEligibleAccounts.length === 0}
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400 disabled:opacity-60"
+ >
+ {collectVCEligibleAccounts.length === 0 ? (
+
+ No {collectVCPaymentMode === 'CASH' ? 'cash' : 'bank'} account configured
+
+ ) : (
+ <>
+ Select account...
+ {collectVCEligibleAccounts.map((a) => (
+
+ {a.name}
+
+ ))}
+ >
+ )}
+
+
)}
+
+ {collectVCPaymentMode === 'CHEQUE' && (
+
+ )}
setCollectVCModal(null)}>
Cancel
@@ -6613,11 +7277,167 @@ export default function ServiceDashboardPage() {
disabled={
collectVCSubmitting ||
!collectVCPaymentMode ||
- (collectVCPaymentMode !== 'CHEQUE' && !collectVCAccountId)
+ (collectVCPaymentMode !== 'CHEQUE' && !collectVCAccountId) ||
+ (collectVCPaymentMode === 'CHEQUE' && !collectVCChequeNumber.trim())
}
onClick={handleCollectVisitChargeNow}
>
- {collectVCSubmitting ? 'Collecting...' : 'Confirm Payment'}
+ {collectVCSubmitting ? 'Sending...' : 'Send to Accounts for Approval'}
+
+
+
+ )}
+
+
+ setCollectCPModal(null)}
+ maxWidth="sm"
+ title="Collect Payment"
+ >
+ {collectCPModal && (
+
+
+ Ticket {collectCPModal.ticketNumber} — record a payment the customer made after the
+ job was completed.
+
+ {collectCPLoadingDue ? (
+
Loading outstanding balance…
+ ) : collectCPAmountDue ? (
+ collectCPAmountDue.outstanding > 0 ? (
+
+ Invoice {collectCPAmountDue.invoiceNumber || ''}: {getActiveCurrency()}{' '}
+ {collectCPAmountDue.total.toFixed(2)} total, {getActiveCurrency()}{' '}
+ {collectCPAmountDue.paid.toFixed(2)} already collected/pending —{' '}
+
+ {getActiveCurrency()} {collectCPAmountDue.outstanding.toFixed(2)} outstanding
+
+ .
+
+ ) : (
+
+ Nothing outstanding — this invoice is already fully collected or pending approval.
+
+ )
+ ) : (
+
Could not load the invoice balance.
+ )}
+
+ Recording this sends the collection to Accounts for approval. Nothing is posted to the
+ cashbook until they approve it, into whichever account is selected here.
+
+
+
+
+ Amount ({getActiveCurrency()})
+
+
setCollectCPAmount(e.target.value)}
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400"
+ />
+ {collectCPAmountDue &&
+ Number(collectCPAmount) > collectCPAmountDue.outstanding + 0.01 && (
+
+ More than the {getActiveCurrency()}{' '}
+ {collectCPAmountDue.outstanding.toFixed(2)} outstanding.
+
+ )}
+
+
+
+ Payment Mode
+
+ {
+ setCollectCPPaymentMode(e.target.value);
+ setCollectCPAccountId('');
+ }}
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border border-orange-200 rounded-xl text-orange-900 font-semibold focus:outline-none focus:ring-2 focus:ring-orange-400 focus:border-orange-400"
+ >
+ Select mode...
+ Cash
+ Bank Transfer
+ Cheque
+
+
+
+
+ {collectCPPaymentMode && collectCPPaymentMode !== 'CHEQUE' && (
+
+
+ {collectCPPaymentMode === 'CASH' ? 'Cash Account' : 'Bank Account'}
+
+ ({ value: a.id, label: a.name }))}
+ value={collectCPAccountId}
+ onValueChange={(val) => setCollectCPAccountId(val)}
+ placeholder={
+ collectCPEligibleAccounts.length === 0
+ ? `No ${collectCPPaymentMode === 'CASH' ? 'cash' : 'bank'} account configured`
+ : 'Select account...'
+ }
+ disabled={collectCPEligibleAccounts.length === 0}
+ className="w-full h-9 px-3 text-xs bg-orange-50/60 border-orange-200 rounded-xl text-orange-900 font-semibold"
+ />
+
+ )}
+
+ {collectCPPaymentMode === 'CHEQUE' && (
+
+ )}
+
+ setCollectCPModal(null)}>
+ Cancel
+
+ 0) ||
+ !collectCPPaymentMode ||
+ (collectCPPaymentMode !== 'CHEQUE' && !collectCPAccountId) ||
+ (collectCPPaymentMode === 'CHEQUE' && !collectCPChequeNumber.trim())
+ }
+ onClick={handleCollectCompletionPayment}
+ >
+ {collectCPSubmitting ? 'Sending...' : 'Send to Accounts for Approval'}
diff --git a/frontend/app/finance/(dashboard)/service-estimates/page.tsx b/frontend/app/finance/(dashboard)/service-estimates/page.tsx
index 355e0a0a..c2999a2d 100644
--- a/frontend/app/finance/(dashboard)/service-estimates/page.tsx
+++ b/frontend/app/finance/(dashboard)/service-estimates/page.tsx
@@ -427,22 +427,22 @@ export default function FinanceServiceEstimatesPage() {
!v && setDetailTarget(null)}>
{/* Header band */}
-
+
-
+
Service Estimate Review
-
+
Items, charges and technician context for this estimate.
-
+
{detailTarget.invoiceNumber}
{(detailTarget.revisionCount || 0) > 0 && (
-
+
Revision {detailTarget.revisionCount}
)}
@@ -473,13 +473,13 @@ export default function FinanceServiceEstimatesPage() {
].map((f) => (
{f.label}
);
}
diff --git a/frontend/app/manager/(dashboard)/products/[id]/page.tsx b/frontend/app/manager/(dashboard)/products/[id]/page.tsx
index 1cd100d0..413c8f68 100644
--- a/frontend/app/manager/(dashboard)/products/[id]/page.tsx
+++ b/frontend/app/manager/(dashboard)/products/[id]/page.tsx
@@ -27,6 +27,7 @@ import { useBranchCurrency } from '@/lib/hooks/useBranchCurrency';
import { toast } from 'sonner';
import Barcode from 'react-barcode';
import { resolveImageUrl } from '@/lib/imageUrl';
+import MachineServiceAnalyticsPanel from '@/components/products/MachineServiceAnalyticsPanel';
interface ProductFeature {
subHeading: string;
@@ -444,6 +445,15 @@ export default function ProductDetailPage() {
+ {/* Service & Spend Analytics — real internal cost, works for RENT/LEASE/
+ SALE and external machines alike. Staff-only, never customer-facing. */}
+
+
+ Service & Spend History
+
+
+
+
{/* Image Preview Overlay Modal */}
{previewImage && (
diff --git a/frontend/components/Finance/ConsolidatedStatementDialog.tsx b/frontend/components/Finance/ConsolidatedStatementDialog.tsx
index 04b300a8..8ec72126 100644
--- a/frontend/components/Finance/ConsolidatedStatementDialog.tsx
+++ b/frontend/components/Finance/ConsolidatedStatementDialog.tsx
@@ -116,7 +116,10 @@ export default function ConsolidatedStatementDialog({
return (
!val && onClose()}>
-
+
diff --git a/frontend/components/Finance/ReceiptsTab.tsx b/frontend/components/Finance/ReceiptsTab.tsx
index 0842aa92..b0f617c6 100644
--- a/frontend/components/Finance/ReceiptsTab.tsx
+++ b/frontend/components/Finance/ReceiptsTab.tsx
@@ -35,7 +35,15 @@ import {
type ActionType as ChequeActionType,
} from '@/components/accounts/ChequeDetailModal';
import { useQuery } from '@tanstack/react-query';
-import { getInvoiceById, Invoice } from '@/lib/invoice';
+import {
+ getInvoiceById,
+ getPendingServiceEstimates,
+ getCustomerAcceptedServiceEstimates,
+ confirmServiceEstimateToAccounts,
+ financeApproveQuotation,
+ financeRejectInvoice,
+ Invoice,
+} from '@/lib/invoice';
import { getApiErrorMessage } from '@/lib/apiError';
import { getActiveCurrency } from '@/lib/currency';
import { toast } from 'sonner';
@@ -63,6 +71,7 @@ import {
Dialog,
DialogContent,
DialogTitle,
+ DialogHeader,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
@@ -302,10 +311,11 @@ function CustomerChequesSection({ branchIds }: { branchIds?: string }) {
}
// paymentContext → contract type label
-const ctxType = (ctx?: string | null): 'SALE' | 'RENT' | 'LEASE' | null => {
+const ctxType = (ctx?: string | null): 'SALE' | 'RENT' | 'LEASE' | 'SERVICE' | null => {
if (!ctx || ctx === 'SALE') return 'SALE';
if (ctx.startsWith('RENT')) return 'RENT';
if (ctx.startsWith('LEASE')) return 'LEASE';
+ if (ctx.startsWith('SERVICE_CONTRACT')) return 'SERVICE';
return null;
};
@@ -373,7 +383,9 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
const [tab, setTab] = useState('PENDING');
// Secondary filters
- const [typeFilter, setTypeFilter] = useState<'ALL' | 'SALE' | 'RENT' | 'LEASE'>('ALL');
+ const [typeFilter, setTypeFilter] = useState<'ALL' | 'SALE' | 'RENT' | 'LEASE' | 'SERVICE'>(
+ 'ALL',
+ );
const [modeFilter, setModeFilter] = useState<
'ALL' | 'CASH' | 'BANK_TRANSFER' | 'CHEQUE' | 'ONLINE_PAYMENT'
>('ALL');
@@ -470,6 +482,103 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
setDirectCardQuoteError(null);
};
+ /**
+ * Service estimates a technician has sent up for Finance sign-off.
+ *
+ * They are Invoices, not SalePaymentRequests, so they can never arrive in `payments`
+ * above — nothing has been collected yet, which is why Accounts saw "No payments in this
+ * category" while a 270 estimate sat waiting. They are rendered as rows of THIS table
+ * rather than a panel of their own: Accounts work one approval queue, and a second list
+ * under the first is a second place to forget to look.
+ *
+ * Only shown on PENDING, because that is the only tab whose meaning they share.
+ */
+ const [viewEstimate, setViewEstimate] = useState(null);
+ const [estimateBusyId, setEstimateBusyId] = useState(null);
+ const [rejectEstimate, setRejectEstimate] = useState(null);
+ const [rejectEstimateReason, setRejectEstimateReason] = useState('');
+
+ const { data: pendingEstimates = [], refetch: refetchEstimates } = useQuery({
+ queryKey: ['pending-service-estimates'],
+ queryFn: getPendingServiceEstimates,
+ staleTime: 30_000,
+ });
+
+ /**
+ * Estimates the customer has already accepted, waiting for Accounts to raise the
+ * receivable. A second, later queue than the one above: Finance prices the job, the
+ * customer accepts it, and only then is there money to put in the books.
+ */
+ const { data: acceptedEstimates = [], refetch: refetchAccepted } = useQuery({
+ queryKey: ['customer-accepted-service-estimates'],
+ queryFn: getCustomerAcceptedServiceEstimates,
+ staleTime: 30_000,
+ });
+
+ const takeIntoAccounts = async (inv: Invoice) => {
+ setEstimateBusyId(inv.id);
+ try {
+ const updated = await confirmServiceEstimateToAccounts(inv.id);
+ toast.success(`Receivable raised for ${updated.invoiceNumber ?? inv.invoiceNumber}`, {
+ description: 'The estimate is now an invoice and shows in Accounts Receivable.',
+ });
+ await Promise.all([refetchAccepted(), loadData()]);
+ } catch (err) {
+ toast.error('Could not take this into accounts', { description: getApiErrorMessage(err) });
+ } finally {
+ setEstimateBusyId(null);
+ }
+ };
+
+ /**
+ * What the estimate is worth.
+ *
+ * `totalAmount` is the figure Finance approves against, so it wins. It is only
+ * recomputed from the parts when it is missing or zero but the estimate plainly has
+ * value — a sync gap between the ticket and its invoice should not show Accounts a
+ * blank cheque to approve.
+ */
+ const estimateAmount = (inv: Invoice): number => {
+ const stored = Number(inv.totalAmount) || 0;
+ if (stored > 0) return stored;
+ const items = (inv.items || []).reduce(
+ (sum, it) => sum + (Number(it.quantity) || 0) * (Number(it.unitPrice) || 0),
+ 0,
+ );
+ const rebuilt =
+ items + (Number(inv.visitChargeAmount) || 0) - (Number(inv.discountAmount) || 0);
+ return rebuilt > 0 ? rebuilt : stored;
+ };
+
+ const approveEstimate = async (inv: Invoice) => {
+ setEstimateBusyId(inv.id);
+ try {
+ await financeApproveQuotation(inv.id);
+ toast.success(`Service estimate ${inv.invoiceNumber} approved`);
+ await refetchEstimates();
+ } catch (err) {
+ toast.error('Failed to approve estimate', { description: getApiErrorMessage(err) });
+ } finally {
+ setEstimateBusyId(null);
+ }
+ };
+
+ const submitEstimateRejection = async () => {
+ if (!rejectEstimate || rejectEstimateReason.trim().length < 5) return;
+ setEstimateBusyId(rejectEstimate.id);
+ try {
+ await financeRejectInvoice(rejectEstimate.id, rejectEstimateReason.trim());
+ toast.success(`Service estimate ${rejectEstimate.invoiceNumber} rejected`);
+ setRejectEstimate(null);
+ setRejectEstimateReason('');
+ await refetchEstimates();
+ } catch (err) {
+ toast.error('Failed to reject estimate', { description: getApiErrorMessage(err) });
+ } finally {
+ setEstimateBusyId(null);
+ }
+ };
+
const loadData = useCallback(async () => {
setIsLoading(true);
try {
@@ -830,8 +939,16 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
const sumAmount = (list: SalePaymentRequest[]) =>
list.reduce((s, p) => s + Number(p.amount || 0), 0);
+ const showEstimateRows = tab === 'PENDING' && pendingEstimates.length > 0;
+ const showAcceptedRows = tab === 'PENDING' && acceptedEstimates.length > 0;
+
const counts = {
- PENDING: filteredBase.filter((p) => p.status === 'PENDING').length,
+ // Estimates count toward Pending too — the headline must agree with the rows below it,
+ // or "0 Awaiting approval" sits above a list of things plainly awaiting approval.
+ PENDING:
+ filteredBase.filter((p) => p.status === 'PENDING').length +
+ pendingEstimates.length +
+ acceptedEstimates.length,
APPROVED: filteredBase.filter((p) => p.status === 'APPROVED').length,
REJECTED: filteredBase.filter((p) => p.status === 'REJECTED').length,
ALL: filteredBase.length,
@@ -1056,6 +1173,7 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
Sale
Rent
Lease
+ Service Contract
@@ -1320,7 +1438,7 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
- ) : filtered.length === 0 ? (
+ ) : filtered.length === 0 && !showEstimateRows && !showAcceptedRows ? (
No payments in this category
@@ -1342,6 +1460,149 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
+ {showEstimateRows &&
+ pendingEstimates.map((inv) => (
+
+
+ {inv.invoiceNumber}
+
+
+ —
+
+
+ {inv.customerName || '—'}
+
+
+
+ Service Estimate
+
+
+
+ {/* An estimate has no payment mode because no money has moved.
+ Saying so beats a bare dash, which reads as missing data. */}
+
+ Not Collected
+
+
+
+ {formatCurrency(estimateAmount(inv), currency)}
+
+
+ {inv.createdAt
+ ? new Date(inv.createdAt).toLocaleDateString('en-GB')
+ : '—'}
+
+
+ {inv.employeeName || '—'}
+
+
+
+ Awaiting Approval
+
+
+
+
+ setViewEstimate(inv)}
+ >
+
+
+ approveEstimate(inv)}
+ >
+ Approve
+
+ {
+ setRejectEstimate(inv);
+ setRejectEstimateReason('');
+ }}
+ >
+ Reject
+
+
+
+
+ ))}
+ {showAcceptedRows &&
+ acceptedEstimates.map((inv) => (
+
+
+ {inv.invoiceNumber}
+
+
+ —
+
+
+ {inv.customerName || '—'}
+
+
+
+ Customer Accepted
+
+
+
+
+ Not Collected
+
+
+
+ {formatCurrency(estimateAmount(inv), currency)}
+
+
+ {inv.createdAt
+ ? new Date(inv.createdAt).toLocaleDateString('en-GB')
+ : '—'}
+
+
+ {inv.employeeName || '—'}
+
+
+
+ Raise Receivable
+
+
+
+
+ setViewEstimate(inv)}
+ >
+
+
+ takeIntoAccounts(inv)}
+ >
+ Take into Accounts
+
+
+
+
+ ))}
{filtered.map((pmt) => (
@@ -1510,6 +1771,186 @@ export default function ReceiptsTab({ branchIds }: { branchIds?: string } = {})
)}
+ {/* What Finance is actually approving: the technician's costing, broken out.
+ Approving a bare total means approving a number nobody has checked. */}
+ {viewEstimate && (
+ setViewEstimate(null)}>
+
+
+
+ Service Estimate {viewEstimate.invoiceNumber}
+
+ Awaiting Approval
+
+
+
+
+
+
+
+
+ Customer
+
+
{viewEstimate.customerName || '—'}
+
+
+
+ Raised By
+
+
{viewEstimate.employeeName || '—'}
+
+
+
+ Date
+
+
+ {viewEstimate.createdAt
+ ? new Date(viewEstimate.createdAt).toLocaleDateString('en-GB')
+ : '—'}
+
+
+
+
+ Service Ticket
+
+
+ {viewEstimate.serviceTicketId
+ ? viewEstimate.serviceTicketId.slice(0, 8).toUpperCase()
+ : '—'}
+
+
+
+
+
+
+ Costing
+
+
+ {(viewEstimate.items || []).length === 0 && (
+
+ No line items recorded on this estimate.
+
+ )}
+ {(viewEstimate.items || []).map((it, i) => (
+
+
+ {it.description}
+ {Number(it.quantity) > 1 ? ` × ${it.quantity}` : ''}
+
+
+ {formatCurrency(
+ (Number(it.quantity) || 0) * (Number(it.unitPrice) || 0),
+ currency,
+ )}
+
+
+ ))}
+ {Number(viewEstimate.visitChargeAmount) > 0 && (
+
+ Visit Charge
+
+ {formatCurrency(Number(viewEstimate.visitChargeAmount), currency)}
+
+
+ )}
+ {Number(viewEstimate.discountAmount) > 0 && (
+
+ Discount
+
+ − {formatCurrency(Number(viewEstimate.discountAmount), currency)}
+
+
+ )}
+
+
+ Total
+
+
+ {formatCurrency(estimateAmount(viewEstimate), currency)}
+
+
+
+
+
+ {viewEstimate.technicianNoteToFinance && (
+
+
+ Technician Note to Finance
+
+
+ {viewEstimate.technicianNoteToFinance}
+
+
+ )}
+
+
+
+ setViewEstimate(null)}>
+ Close
+
+ {
+ setRejectEstimate(viewEstimate);
+ setRejectEstimateReason('');
+ setViewEstimate(null);
+ }}
+ >
+ Reject
+
+ {
+ approveEstimate(viewEstimate);
+ setViewEstimate(null);
+ }}
+ >
+ Approve
+
+
+
+
+ )}
+
+ {/* Reject dialog for a service estimate — the reason goes back to the technician,
+ who can then revise and resubmit. */}
+ {rejectEstimate && (
+ setRejectEstimate(null)}>
+
+
+ Reject {rejectEstimate.invoiceNumber}
+
+
+
+ The technician sees this reason and can revise the estimate.
+
+
+
+ setRejectEstimate(null)}>
+ Cancel
+
+
+ Reject Estimate
+
+
+
+
+ )}
+
{/* Customer Cheques (Received) */}
diff --git a/frontend/components/Finance/UsageHistoryDialog.tsx b/frontend/components/Finance/UsageHistoryDialog.tsx
index 299cade7..425e7251 100644
--- a/frontend/components/Finance/UsageHistoryDialog.tsx
+++ b/frontend/components/Finance/UsageHistoryDialog.tsx
@@ -707,7 +707,10 @@ export default function UsageHistoryDialog({
{/* Image Preview Dialog */}
setPreviewImage(null)}>
-
+
Meter Reading Image Preview
Visual confirmation of the meter reading for this billing period.
diff --git a/frontend/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog.tsx b/frontend/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog.tsx
index cb17e715..d6088042 100644
--- a/frontend/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog.tsx
+++ b/frontend/components/ManagerDashboardComponents/BrandComponents/AddBrandDialog.tsx
@@ -29,7 +29,9 @@ type FormData = z.infer;
interface AddBrandDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- onSuccess: () => void;
+ /** Called after a successful create/edit. The created/updated brand is
+ * passed through so a caller can e.g. auto-select it in a form. */
+ onSuccess: (brand?: Brand) => void;
/** Pass a brand to switch the dialog into edit mode */
initialData?: Brand | null;
}
@@ -73,15 +75,18 @@ export function AddBrandDialog({
const onSubmit = async (data: FormData) => {
try {
setLoading(true);
+ let saved: Brand | undefined;
if (isEditing && initialData) {
- await updateBrand(initialData.id, data);
+ const res = await updateBrand(initialData.id, data);
+ saved = res?.data ?? { ...initialData, ...data };
toast.success('Brand updated successfully');
} else {
- await createBrand(data);
+ const res = await createBrand(data);
+ saved = res?.data;
toast.success('Brand created successfully');
}
reset();
- onSuccess();
+ onSuccess(saved);
onOpenChange(false);
} catch (error: unknown) {
console.error('error saving brand', error);
diff --git a/frontend/components/ManagerDashboardComponents/RfqComponents/RfqCreateForm.tsx b/frontend/components/ManagerDashboardComponents/RfqComponents/RfqCreateForm.tsx
index 892247d8..3c01a129 100644
--- a/frontend/components/ManagerDashboardComponents/RfqComponents/RfqCreateForm.tsx
+++ b/frontend/components/ManagerDashboardComponents/RfqComponents/RfqCreateForm.tsx
@@ -16,6 +16,7 @@ import { getAllModels, Model, formatModelLabel } from '@/lib/model';
import { getAllSpareParts, SparePart } from '@/lib/spare-part';
import { getAllProducts, Product } from '@/lib/product';
import { getBrands, Brand } from '@/lib/brand';
+import { getServiceTicketById } from '@/lib/serviceTicket';
import { Button } from '@/components/ui/button';
import {
Trash2,
@@ -51,6 +52,7 @@ export default function RfqCreateForm({ basePath }: RfqCreateFormProps) {
const vendorIdParam = searchParams.get('vendorId');
const editRfqId = searchParams.get('edit');
const isEditMode = !!editRfqId;
+ const fromServiceTicketId = searchParams.get('fromServiceTicket');
const [loading, setLoading] = useState(false);
const [loadingRfq, setLoadingRfq] = useState(isEditMode);
@@ -102,6 +104,45 @@ export default function RfqCreateForm({ basePath }: RfqCreateFormProps) {
}
}, [vendorIdParam]);
+ // Arrived here from a technician's "custom part" notification — seed one
+ // RFQ row per off-catalog item on that ticket so the manager doesn't have
+ // to retype what the technician already described.
+ useEffect(() => {
+ if (!fromServiceTicketId) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const ticket = await getServiceTicketById(fromServiceTicketId);
+ if (cancelled) return;
+ const customItems = (ticket.items || []).filter((it) => it.itemSource === 'CUSTOM');
+ if (customItems.length === 0) {
+ toast.error('That ticket has no custom (off-catalog) parts to request.');
+ return;
+ }
+ setItems((prev) => [
+ ...prev,
+ ...customItems.map((it) => ({
+ itemType: ItemType.SPARE_PART,
+ customSparePartName: it.customPartName || it.partName,
+ customBrandName: it.customPartBrand || undefined,
+ description: it.customPartDescription || undefined,
+ mpn: it.mpn || undefined,
+ quantity: it.quantity || 1,
+ })),
+ ]);
+ toast.success(
+ `Pre-filled ${customItems.length} custom part${customItems.length > 1 ? 's' : ''} from ticket ${ticket.ticketNumber}.`,
+ );
+ } catch (error) {
+ console.error('Failed to load service ticket for RFQ prefill', error);
+ toast.error('Could not load that service ticket to pre-fill the request.');
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [fromServiceTicketId]);
+
// Edit mode: load the existing draft and prefill the form. Backend
// updateRfq() 400s on anything past DRAFT, so bounce back to the detail
// page rather than let the user fill out a form that can't submit.
diff --git a/frontend/components/ManagerDashboardComponents/productComponents/AddModelDialog.tsx b/frontend/components/ManagerDashboardComponents/productComponents/AddModelDialog.tsx
index 210bfed9..991adcf8 100644
--- a/frontend/components/ManagerDashboardComponents/productComponents/AddModelDialog.tsx
+++ b/frontend/components/ManagerDashboardComponents/productComponents/AddModelDialog.tsx
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
-import { modelService, CreateModelDTO } from '@/services/modelService';
+import { modelService, CreateModelDTO, Model } from '@/services/modelService';
import { getBrands, Brand } from '@/lib/brand';
import { SearchableSelect, SearchableSelectOption } from '@/components/ui/searchable-select';
@@ -23,10 +23,20 @@ type FormData = z.infer;
interface AddModelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- onSuccess: () => void;
+ /** Called after a successful create. The created model is passed through
+ * so a caller can e.g. auto-select it in a form. */
+ onSuccess: (model?: Model) => void;
+ /** Pre-selects this brand when the dialog opens — e.g. when a caller
+ * already has a brand chosen elsewhere in its own form. */
+ initialBrandId?: string;
}
-export function AddModelDialog({ open, onOpenChange, onSuccess }: AddModelDialogProps) {
+export function AddModelDialog({
+ open,
+ onOpenChange,
+ onSuccess,
+ initialBrandId,
+}: AddModelDialogProps) {
const [loading, setLoading] = useState(false);
const [brands, setBrands] = useState([]);
@@ -49,15 +59,22 @@ export function AddModelDialog({ open, onOpenChange, onSuccess }: AddModelDialog
resolver: zodResolver(formSchema),
});
+ useEffect(() => {
+ if (open) {
+ reset({ model_no: '', model_name: '', brand_id: initialBrandId || '', description: '' });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, initialBrandId]);
+
const brandIdValue = useWatch({ control, name: 'brand_id' });
const onSubmit = async (data: FormData) => {
try {
setLoading(true);
- await modelService.createModel(data as CreateModelDTO);
+ const created = await modelService.createModel(data as CreateModelDTO);
toast.success('Model created successfully');
reset();
- onSuccess();
+ onSuccess(created);
onOpenChange(false);
} catch (error: unknown) {
console.error('error creating model', error);
diff --git a/frontend/components/NotificationsPage.tsx b/frontend/components/NotificationsPage.tsx
index 8fd912fe..62d323cd 100644
--- a/frontend/components/NotificationsPage.tsx
+++ b/frontend/components/NotificationsPage.tsx
@@ -132,6 +132,9 @@ export default function NotificationsPage({ role }: { role: string }) {
case 'OPENING_BALANCE':
router.push(`/${role}/opening-balances`);
break;
+ case 'CUSTOM_PART_REQUEST':
+ router.push(`/${role}/rfqs/create?fromServiceTicket=${refId}`);
+ break;
default:
break;
}
diff --git a/frontend/components/SessionsDialog.tsx b/frontend/components/SessionsDialog.tsx
index 5591da4c..e8da7fd1 100644
--- a/frontend/components/SessionsDialog.tsx
+++ b/frontend/components/SessionsDialog.tsx
@@ -10,10 +10,18 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
-import { getSessions, logoutSession, logoutOtherDevices } from '@/lib/auth';
-import { Laptop, Smartphone, Globe, LogOut, ShieldCheck, Clock } from 'lucide-react';
+import {
+ getSessions,
+ logoutSession,
+ logoutOtherDevices,
+ getTrustedDevices,
+ revokeTrustedDevice,
+ revokeAllTrustedDevices,
+} from '@/lib/auth';
+import { Laptop, Smartphone, Globe, LogOut, ShieldCheck, Clock, ShieldOff } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
interface Session {
id: string;
@@ -23,6 +31,14 @@ interface Session {
isCurrent: boolean;
}
+interface TrustedDevice {
+ id: string;
+ device_name: string | null;
+ ip_address: string | null;
+ last_used_at: string;
+ expires_at: string;
+}
+
interface SessionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -35,6 +51,8 @@ interface SessionsDialogProps {
export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) {
const [sessions, setSessions] = useState([]);
const [loading, setLoading] = useState(false);
+ const [trustedDevices, setTrustedDevices] = useState([]);
+ const [trustedLoading, setTrustedLoading] = useState(false);
const fetchSessions = async () => {
setLoading(true);
@@ -50,12 +68,51 @@ export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) {
}
};
+ const fetchTrustedDevices = async () => {
+ setTrustedLoading(true);
+ try {
+ const res = await getTrustedDevices();
+ if (res.success) {
+ setTrustedDevices(res.data);
+ }
+ } catch {
+ toast.error('Failed to load trusted devices');
+ } finally {
+ setTrustedLoading(false);
+ }
+ };
+
useEffect(() => {
if (open) {
fetchSessions();
+ fetchTrustedDevices();
}
}, [open]);
+ const handleRevokeTrustedDevice = async (deviceId: string) => {
+ try {
+ const res = await revokeTrustedDevice(deviceId);
+ if (res.success) {
+ toast.success('Trusted device revoked');
+ fetchTrustedDevices();
+ }
+ } catch {
+ toast.error('Failed to revoke trusted device');
+ }
+ };
+
+ const handleRevokeAllTrustedDevices = async () => {
+ try {
+ const res = await revokeAllTrustedDevices();
+ if (res.success) {
+ toast.success('All trusted devices revoked — OTP required on next login everywhere');
+ fetchTrustedDevices();
+ }
+ } catch {
+ toast.error('Failed to revoke trusted devices');
+ }
+ };
+
const handleLogoutSession = async (sessionId: string) => {
try {
const res = await logoutSession(sessionId);
@@ -148,106 +205,191 @@ export function SessionsDialog({ open, onOpenChange }: SessionsDialogProps) {
- Active Sessions
+ Account Security
- Manage devices where your account is currently logged in.
+ Manage where your account is logged in and which browsers skip OTP.
-
-
- {loading ? (
-
-
- Loading active sessions...
-
- ) : sortedSessions.length === 0 ? (
-
- No active sessions found.
-
- ) : (
- sortedSessions.map((session) => (
-
-
+
+
+ Active Sessions
+ Trusted Devices
+
+
+
+
+
+ {loading ? (
+
+
+ Loading active sessions...
+
+ ) : sortedSessions.length === 0 ? (
+
+ No active sessions found.
+
+ ) : (
+ sortedSessions.map((session) => (
- {getDeviceIcon(session.userAgent)}
-
+
+
+ {getDeviceIcon(session.userAgent)}
+
-
-
-
- {getDeviceName(session.userAgent)}
-
- {session.isCurrent && (
-
- Current Device
-
- )}
-
+
+
+
+ {getDeviceName(session.userAgent)}
+
+ {session.isCurrent && (
+
+ Current Device
+
+ )}
+
-
-
-
- {session.ip || 'Unknown IP'}
-
-
-
- {session.isCurrent
- ? 'Active now'
- : `Last active ${formatDistanceToNow(new Date(session.createdAt), { addSuffix: true })}`}
-
+
+
+
+ {session.ip || 'Unknown IP'}
+
+
+
+ {session.isCurrent
+ ? 'Active now'
+ : `Last active ${formatDistanceToNow(new Date(session.createdAt), { addSuffix: true })}`}
+
+
+
+
+ {!session.isCurrent && (
+
handleLogoutSession(session.id)}
+ className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive w-full sm:w-auto mt-2 sm:mt-0"
+ >
+
+ Log out
+
+ )}
-
+ ))
+ )}
+
+
- {!session.isCurrent && (
- handleLogoutSession(session.id)}
- className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive w-full sm:w-auto mt-2 sm:mt-0"
+ {sortedSessions.length > 1 && (
+
+
+
+ Log out all other devices
+
+
+ )}
+
+
+
+
+
+ {trustedLoading ? (
+
+
+ Loading trusted devices...
+
+ ) : trustedDevices.length === 0 ? (
+
+ No trusted devices — every login on every browser currently requires OTP.
+
+ ) : (
+ trustedDevices.map((device) => (
+
-
- Log out
-
- )}
-
- ))
+
+
+ {getDeviceIcon(device.device_name || undefined)}
+
+
+
+
+ {device.device_name
+ ? getDeviceName(device.device_name)
+ : 'Unknown Device'}
+
+
+
+
+
+ {device.ip_address || 'Unknown IP'}
+
+
+
+ Last used{' '}
+ {formatDistanceToNow(new Date(device.last_used_at), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
handleRevokeTrustedDevice(device.id)}
+ className="shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive w-full sm:w-auto mt-2 sm:mt-0"
+ >
+
+ Revoke
+
+
+ ))
+ )}
+
+
+
+ {trustedDevices.length > 0 && (
+
+
+
+ Revoke all devices
+
+
)}
-
-
-
- {sortedSessions.length > 1 && (
-
-
-
- Log out all other devices
-
-
- )}
+
+
);
diff --git a/frontend/components/customer360/Customer360View.tsx b/frontend/components/customer360/Customer360View.tsx
index 7fbfba92..0641932d 100644
--- a/frontend/components/customer360/Customer360View.tsx
+++ b/frontend/components/customer360/Customer360View.tsx
@@ -52,8 +52,10 @@ import { ContractAgreementModal } from '@/components/employeeComponents/Contract
import { BillModal } from '@/components/Finance/BillModal';
import CreditNoteViewModal from '@/components/returns/CreditNoteViewModal';
import type { CreditNoteRecord } from '@/lib/invoice';
+import CustomerProductsPanel from './CustomerProductsPanel';
type Tab =
+ | 'products'
| 'quotations'
| 'contracts'
| 'bills'
@@ -165,6 +167,10 @@ interface Props {
/** Shown as a banner under the header when this profile is scoped narrower than the
* full branch history (e.g. the Employee personal-only view). */
scopeNotice?: string;
+ /** e.g. "/manager" or "/admin" — only passed by roles with a product detail
+ * page, so the Products & Services tab can link machine cards through to
+ * it. Omit for roles without one (employee/finance/hr). */
+ productBasePath?: string;
}
export default function Customer360View({
@@ -175,10 +181,11 @@ export default function Customer360View({
createdByRole,
backHref,
scopeNotice,
+ productBasePath,
}: Props) {
const router = useRouter();
const currency = useBranchCurrency();
- const [activeTab, setActiveTab] = useState
('contracts');
+ const [activeTab, setActiveTab] = useState('products');
// Contract Agreement modal state
const [agreementInvoice, setAgreementInvoice] = useState(null);
@@ -218,6 +225,7 @@ export default function Customer360View({
}));
const tabs: { id: Tab; label: string; count: number }[] = [
+ { id: 'products', label: 'Products & Services', count: 0 },
{ id: 'contracts', label: 'Contracts', count: contracts.length },
{ id: 'quotations', label: 'Quotations', count: quotations.length },
{ id: 'bills', label: 'Rent/Lease Bills', count: bills.length },
@@ -466,6 +474,9 @@ export default function Customer360View({
+ {activeTab === 'products' && (
+
+ )}
{activeTab === 'contracts' && (
= {
+ SALE: { label: 'Purchased', badgeClass: 'bg-blue-50 text-blue-700 border-blue-100' },
+ RENT: {
+ label: 'Rented from us',
+ badgeClass: 'bg-emerald-50 text-emerald-700 border-emerald-100',
+ },
+ LEASE: { label: 'Leased from us', badgeClass: 'bg-purple-50 text-purple-700 border-purple-100' },
+ AMC: { label: 'AMC Contract', badgeClass: 'bg-amber-50 text-amber-700 border-amber-100' },
+ SMA: { label: 'SMA Contract', badgeClass: 'bg-amber-50 text-amber-700 border-amber-100' },
+ FSMA: { label: 'FSMA Contract', badgeClass: 'bg-amber-50 text-amber-700 border-amber-100' },
+ EXTERNAL: {
+ label: 'Under our service (external machine)',
+ badgeClass: 'bg-slate-100 text-slate-600 border-slate-200',
+ },
+};
+
+/**
+ * Every machine relationship a customer has with us: bought, rented, leased,
+ * under an AMC/SMA/FSMA contract (even on a third-party machine we never
+ * sold them), or serviced ad-hoc with no contract at all. Reuses the same
+ * customer→machine derivation (`machineAllocations.ts`) already built for
+ * the service-ticket creation machine picker — this just renders it here too.
+ */
+export default function CustomerProductsPanel({
+ customerId,
+ productBasePath,
+}: {
+ customerId: string;
+ /** e.g. "/manager" or "/admin" — only passed by roles that have a product
+ * detail page, so a machine name links through to it. Omit to render
+ * plain, non-clickable cards (e.g. employee/finance/hr views). */
+ productBasePath?: string;
+}) {
+ const router = useRouter();
+ const [history, setHistory] = useState(null);
+ const [contracts, setContracts] = useState([]);
+ const [models, setModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [expandedSerial, setExpandedSerial] = useState(null);
+
+ useEffect(() => {
+ if (!customerId) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ setLoading(true);
+ const [h, c, m] = await Promise.all([
+ getCustomerServiceHistory(customerId),
+ getServiceContracts({ customerId }).catch(() => []),
+ getAllModels({ limit: 1000 }).catch(() => ({ data: [] })),
+ ]);
+ if (cancelled) return;
+ setHistory(h);
+ setContracts(c);
+ setModels(m.data || []);
+ } catch (err) {
+ console.error('Failed to load customer products:', err);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [customerId]);
+
+ const groups = useMemo(() => {
+ if (!history) return [] as { key: string; machines: MachineAllocation[] }[];
+ const activeContracts = contracts.filter((c) => c.status === 'ACTIVE');
+ const purchased = getPurchasedMachines(history, models);
+ const rented = getRentedMachines(history, models);
+ const leased = getLeasedMachines(history, models);
+ const contractMachines = getContractMachines(history, models);
+ const external = getExternalMachines(history, activeContracts);
+
+ const byType: Record = {
+ SALE: purchased,
+ RENT: rented,
+ LEASE: leased,
+ EXTERNAL: external,
+ };
+ // getContractMachines returns AMC/FSMA/SMA mixed together, each item
+ // already carrying its own `type` — split back out per-type for display.
+ for (const m of contractMachines) {
+ (byType[m.type] ||= []).push(m);
+ }
+
+ return Object.entries(byType)
+ .filter(([, list]) => list.length > 0)
+ .map(([key, machines]) => ({ key, machines }));
+ }, [history, contracts, models]);
+
+ // Ad-hoc: chargeable tickets on a machine that's neither a billing
+ // allocation nor a registered (even external) Product — a pure walk-in
+ // repair with only free-text machine info on the ticket itself.
+ const adHocMachines = useMemo(() => {
+ if (!history?.tickets) return [];
+ const knownSerials = new Set(
+ groups.flatMap((g) => g.machines.map((m) => m.serialNumber).filter(Boolean)),
+ );
+ const bySerial = new Map();
+ for (const t of history.tickets) {
+ if (t.productId) continue;
+ const serial = t.serialNumber;
+ if (!serial || knownSerials.has(serial)) continue;
+ const label =
+ [t.productBrand, t.productModel || t.productName].filter(Boolean).join(' — ') ||
+ 'Unregistered machine';
+ const existing = bySerial.get(serial);
+ const ticketDate = t.completedAt || t.created_at;
+ if (existing) {
+ existing.ticketCount += 1;
+ if (ticketDate && (!existing.lastTicketDate || ticketDate > existing.lastTicketDate)) {
+ existing.lastTicketDate = ticketDate;
+ }
+ } else {
+ bySerial.set(serial, {
+ serialNumber: serial,
+ label,
+ ticketCount: 1,
+ lastTicketDate: ticketDate,
+ });
+ }
+ }
+ return Array.from(bySerial.values());
+ }, [history, groups]);
+
+ if (loading) {
+ return (
+
+ Loading products & services…
+
+ );
+ }
+
+ if (groups.length === 0 && adHocMachines.length === 0) {
+ return (
+
+ No products, rentals, leases, contracts, or service history for this customer yet.
+
+ );
+ }
+
+ const goToProduct = (e: React.MouseEvent, id?: string) => {
+ e.stopPropagation();
+ if (!productBasePath || !id) return;
+ router.push(`${productBasePath}/products/${id}`);
+ };
+
+ return (
+
+ {groups.map(({ key, machines }) => {
+ const meta = GROUP_META[key] || {
+ label: key,
+ badgeClass: 'bg-slate-100 text-slate-600 border-slate-200',
+ };
+ return (
+
+
+ {meta.label}
+
+ {machines.length}
+
+
+
+ {machines.map((m) => {
+ const isExpanded = expandedSerial === m.serialNumber;
+ const canOpenProductPage = !!productBasePath && !!m.id;
+ return (
+
setExpandedSerial(isExpanded ? null : m.serialNumber || null)}
+ className={`rounded-xl border p-3 space-y-1 cursor-pointer transition-colors ${
+ isExpanded
+ ? 'border-indigo-300 bg-indigo-50/30'
+ : 'border-slate-200 hover:border-indigo-200 hover:bg-slate-50'
+ }`}
+ >
+
+
+
+ {m.modelName}
+
+
+ {canOpenProductPage && (
+ goToProduct(e, m.id)}
+ title="Open product page"
+ className="text-slate-300 hover:text-indigo-500"
+ >
+
+
+ )}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {m.brandName ? `${m.brandName} • ` : ''}SN: {m.serialNumber}
+
+
+ {m.contractType || key}
+
+ {m.effectiveTo && (
+
+ {key === 'SALE' ? 'Warranty until' : 'Ends'}{' '}
+ {new Date(m.effectiveTo).toLocaleDateString()}
+
+ )}
+ {m.remainingTime && (
+
{m.remainingTime}
+ )}
+
+ );
+ })}
+
+ {machines.some((m) => m.serialNumber && m.serialNumber === expandedSerial) && (
+
+
+ Service & Spend — {expandedSerial}
+
+
+
+ )}
+
+ );
+ })}
+
+ {adHocMachines.length > 0 && (
+
+
+ One-off service (unregistered / external machine)
+
+ {adHocMachines.length}
+
+
+
+ {adHocMachines.map((m) => {
+ const isExpanded = expandedSerial === m.serialNumber;
+ return (
+
+
setExpandedSerial(isExpanded ? null : m.serialNumber)}
+ className="w-full flex items-center justify-between gap-2 p-3 bg-slate-50/30 hover:bg-slate-50 text-left"
+ >
+
+
{m.label}
+
+ SN: {m.serialNumber} • {m.ticketCount} ticket{m.ticketCount > 1 ? 's' : ''}
+ {m.lastTicketDate &&
+ ` • last ${new Date(m.lastTicketDate).toLocaleDateString()}`}
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {isExpanded && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/employeeComponents/InvoiceViewDialog.tsx b/frontend/components/employeeComponents/InvoiceViewDialog.tsx
index 5164a85f..a5ca8c24 100644
--- a/frontend/components/employeeComponents/InvoiceViewDialog.tsx
+++ b/frontend/components/employeeComponents/InvoiceViewDialog.tsx
@@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge';
import { formatCurrency } from '@/lib/format';
import { useBranchCurrency } from '@/lib/hooks/useBranchCurrency';
import { getServiceTicketById, ServiceTicket } from '@/lib/serviceTicket';
+import { getAccountSummary, PaymentSummary } from '@/lib/payment';
interface InternalConsumable {
name?: string;
@@ -109,6 +110,31 @@ export function InvoiceViewDialog({
const [ticketDetails, setTicketDetails] = useState(null);
const [loadingTicket, setLoadingTicket] = useState(false);
+ // Service Contract (AMC/SMA/FSMA) invoices are raised in full at signing and can carry
+ // partial payments approved over time — the generic product/sale quotation layout below
+ // has no notion of that, so the contract value / paid / balance is surfaced separately
+ // here rather than by teaching every shared quotation template about it.
+ const isServiceContractInvoice = (invoice.saleType || '').toUpperCase() === 'SERVICE';
+ const [contractPaymentSummary, setContractPaymentSummary] = useState(null);
+
+ useEffect(() => {
+ if (!isServiceContractInvoice) {
+ setContractPaymentSummary(null);
+ return;
+ }
+ let cancelled = false;
+ getAccountSummary(invoice.id)
+ .then((summary) => {
+ if (!cancelled) setContractPaymentSummary(summary);
+ })
+ .catch(() => {
+ if (!cancelled) setContractPaymentSummary(null);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [invoice.id, isServiceContractInvoice]);
+
useEffect(() => {
if (invoice.serviceTicketId) {
setLoadingTicket(true);
@@ -567,9 +593,15 @@ export function InvoiceViewDialog({
vatPercent: resolvedTaxPercent,
vatName: vatLabel,
total: finalTotalAmount,
- payment: finalTotalAmount,
- balanceDue: finalTotalAmount,
- paid: ['PAID', 'TRANSACTION_COMPLETED'].includes(invoice.status),
+ // A service contract can be invoiced in full at signing and paid off over several
+ // Finance-approved installments — payment/balanceDue reflect that real progress
+ // instead of assuming the whole invoice is either fully paid or fully outstanding.
+ payment: contractPaymentSummary ? contractPaymentSummary.totalPaid : finalTotalAmount,
+ balanceDue: contractPaymentSummary ? contractPaymentSummary.pendingBalance : finalTotalAmount,
+ pendingApprovalAmount: contractPaymentSummary?.pendingApprovalAmount || 0,
+ paid: contractPaymentSummary
+ ? contractPaymentSummary.pendingBalance <= 0
+ : ['PAID', 'TRANSACTION_COMPLETED'].includes(invoice.status),
};
const templateBillTo = {
diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx
index c7ec35ec..0ba60dcb 100644
--- a/frontend/components/login-form.tsx
+++ b/frontend/components/login-form.tsx
@@ -42,10 +42,43 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
const [loading, setLoading] = useState(false); // Used to show "Processing..." on buttons
const [error, setError] = useState(null);
+ /**
+ * Finish logging in: save the Digital ID Card (AccessToken) and send
+ * the staff member to the right department based on their job role.
+ * Shared by the OTP-verify path and the trusted-device (OTP-skipped) path.
+ */
+ const completeLogin = (accessToken: string) => {
+ localStorage.setItem('accessToken', accessToken);
+ document.cookie = `accessToken=${accessToken}; path=/; max-age=86400; SameSite=Strict`;
+
+ try {
+ const decoded = jwtDecode<{ role: string }>(accessToken);
+ const role = decoded.role;
+
+ if (role === 'ADMIN') {
+ window.location.href = '/admin/dashboard';
+ } else if (role === 'HR') {
+ window.location.href = '/hr/dashboard';
+ } else if (role === 'MANAGER') {
+ window.location.href = '/manager/dashboard';
+ } else if (role === 'FINANCE') {
+ window.location.href = '/finance/dashboard';
+ } else if (role === 'EMPLOYEE') {
+ window.location.href = '/employee/dashboard';
+ } else {
+ window.location.href = '/dashboard';
+ }
+ } catch {
+ window.location.href = '/dashboard';
+ }
+ };
+
/**
* STEP 1: Check Password
* When the user clicks "Next" after entering their password, we send
- * a verification code to their email for extra security.
+ * a verification code to their email for extra security — unless this
+ * browser was already verified within the last day, in which case the
+ * server logs them straight in with no OTP step at all.
*/
const handleCredentialsSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -54,7 +87,11 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
try {
const res = await requestLoginOtp(email, password);
- if (res.success) {
+ if (res.accessToken) {
+ // Trusted device — server skipped OTP and logged us in directly.
+ toast.success(res.message);
+ completeLogin(res.accessToken);
+ } else if (res.success) {
toast.success(res.message);
setStep('otp'); // Move to the "Enter Code" screen
} else {
@@ -93,33 +130,7 @@ export function LoginForm({ className, ...props }: React.ComponentProps<'div'>)
const res = await verifyLoginOtp(email, otp);
if (res.success) {
toast.success(res.message);
- // Save the Digital ID Card (AccessToken) in the browser
- // so they stay signed in even if they refresh the page.
- localStorage.setItem('accessToken', res.accessToken);
- document.cookie = `accessToken=${res.accessToken}; path=/; max-age=86400; SameSite=Strict`;
-
- // Check the user's Job Role to decide where to send them.
- try {
- const decoded = jwtDecode<{ role: string }>(res.accessToken);
- const role = decoded.role;
-
- // Redirecting to the right department:
- if (role === 'ADMIN') {
- window.location.href = '/admin/dashboard';
- } else if (role === 'HR') {
- window.location.href = '/hr/dashboard';
- } else if (role === 'MANAGER') {
- window.location.href = '/manager/dashboard';
- } else if (role === 'FINANCE') {
- window.location.href = '/finance/dashboard';
- } else if (role === 'EMPLOYEE') {
- window.location.href = '/employee/dashboard';
- } else {
- window.location.href = '/dashboard';
- }
- } catch {
- window.location.href = '/dashboard';
- }
+ completeLogin(res.accessToken);
} else {
toast.error(res.message);
setError(res.message);
diff --git a/frontend/components/products/MachineServiceAnalyticsPanel.tsx b/frontend/components/products/MachineServiceAnalyticsPanel.tsx
new file mode 100644
index 00000000..3267fe42
--- /dev/null
+++ b/frontend/components/products/MachineServiceAnalyticsPanel.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Loader2 } from 'lucide-react';
+import { formatCurrency } from '@/lib/format';
+import { getMachineAnalytics, MachineAnalytics } from '@/lib/serviceTicket';
+
+/**
+ * Real internal spend + service history for one machine, keyed by
+ * serialNumber alone — works for company-owned (RENT/LEASE/SALE) AND
+ * external machines never purchased from us. Staff-only: internal cost is
+ * never shown on any customer-facing page.
+ */
+export default function MachineServiceAnalyticsPanel({
+ serialNumber,
+ currency,
+}: {
+ serialNumber?: string | null;
+ currency?: string;
+}) {
+ const [analytics, setAnalytics] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (!serialNumber) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ setLoading(true);
+ const data = await getMachineAnalytics(serialNumber);
+ if (!cancelled) setAnalytics(data);
+ } catch (err) {
+ console.error('Failed to load machine service analytics:', err);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [serialNumber]);
+
+ if (loading) {
+ return (
+
+ Loading service history…
+
+ );
+ }
+
+ if (!analytics || analytics.serviceVisitCount === 0) {
+ return No completed service tickets yet.
;
+ }
+
+ return (
+
+
+
+
+ Times Serviced
+
+
{analytics.serviceVisitCount}
+
+
+
+ Parts Spend
+
+
+ {formatCurrency(analytics.lifetimePartsCost, currency)}
+
+
+
+
+ Labour Spend
+
+
+ {formatCurrency(analytics.lifetimeLabourCost, currency)}
+
+
+
+
+ Lifetime Spend
+
+
+ {formatCurrency(analytics.lifetimeSpend, currency)}
+
+
+
+
+
+
+
+
+ Ticket
+ Date
+ Context
+ Parts Used
+ Parts Cost
+ Labour
+ Total Spend
+
+
+
+ {analytics.tickets.map((t) => (
+
+ {t.ticketNumber}
+
+ {t.date ? new Date(t.date).toLocaleDateString() : '—'}
+
+ {t.serviceContext}
+
+ {t.partsUsed.length === 0
+ ? '—'
+ : t.partsUsed.map((p) => `${p.partName} ×${p.quantity}`).join(', ')}
+
+
+ {formatCurrency(t.partsCostInternal, currency)}
+
+
+ {formatCurrency(t.labourCost, currency)}
+
+
+ {formatCurrency(t.totalSpend, currency)}
+
+
+ ))}
+
+
+
+
+ {analytics.toner.yieldHistory.length > 0 && (
+
+
+ Toner Replacements ({analytics.toner.totalTonerReplacements})
+
+
+
+
+
+ Toner SKU
+ Installed
+ Replaced
+ Yield (pages)
+
+
+
+ {analytics.toner.yieldHistory.map((y) => (
+
+ {y.tonerSku}
+
+ {new Date(y.installedDate).toLocaleDateString()}
+
+
+ {y.replacedDate ? new Date(y.replacedDate).toLocaleDateString() : '—'}
+
+
+ {y.yieldPages != null ? y.yieldPages.toLocaleString() : '—'}
+
+
+ ))}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/returns/CreditNoteFormModal.tsx b/frontend/components/returns/CreditNoteFormModal.tsx
index 09db80a7..fc8c4edc 100644
--- a/frontend/components/returns/CreditNoteFormModal.tsx
+++ b/frontend/components/returns/CreditNoteFormModal.tsx
@@ -509,6 +509,7 @@ export default function CreditNoteFormModal({ open, onClose, onSave, record }: P
<>
diff --git a/frontend/components/returns/CreditNoteViewModal.tsx b/frontend/components/returns/CreditNoteViewModal.tsx
index 655e274a..f86123fd 100644
--- a/frontend/components/returns/CreditNoteViewModal.tsx
+++ b/frontend/components/returns/CreditNoteViewModal.tsx
@@ -129,6 +129,7 @@ export default function CreditNoteViewModal({ record, open, onClose }: Props) {
return (
@@ -147,6 +148,12 @@ export default function CreditNoteViewModal({ record, open, onClose }: Props) {
window.print()}>
Print / Save PDF
+
+
+
diff --git a/frontend/components/service/RecordCustomerApprovalDialog.tsx b/frontend/components/service/RecordCustomerApprovalDialog.tsx
index 676a7eff..0b780950 100644
--- a/frontend/components/service/RecordCustomerApprovalDialog.tsx
+++ b/frontend/components/service/RecordCustomerApprovalDialog.tsx
@@ -97,7 +97,7 @@ export function RecordCustomerApprovalDialog({
};
return (
-
+
@@ -109,165 +109,180 @@ export function RecordCustomerApprovalDialog({
-
-
- Who approved it? *
-
- setCustomerName(e.target.value)}
- placeholder="Customer / contact person's full name"
- className="h-9 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-xs"
- />
-
-
-
-
- How did they confirm? *
-
-
- {CHANNELS.map((c) => (
-
- setConfirmedVia(c.value)}
- />
- {c.label}
+ {/* Two columns: who/how/why on the left, the signature on the right. Stacked in one
+ column this form was taller than any laptop screen — the signature pad alone is a
+ third of it — so the two halves sit side by side and it fits without scrolling. */}
+
+
+
+
+ Who approved it? *
- ))}
-
-
-
-
- Note (optional)
-
-
-
-
- Customer signature *
-
+ setCustomerName(e.target.value)}
+ placeholder="Customer / contact person's full name"
+ className="h-9 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-xs"
+ />
+
-
-
setSignMethod('CAPTURE')}
- className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-[10px] font-black uppercase tracking-widest transition-all ${
- signMethod === 'CAPTURE'
- ? 'bg-slate-800 text-white'
- : 'bg-white text-slate-400 hover:bg-slate-50'
- }`}
- >
-
- Capture Signature
-
-
setSignMethod('UPLOAD')}
- className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-[10px] font-black uppercase tracking-widest transition-all ${
- signMethod === 'UPLOAD'
- ? 'bg-slate-800 text-white'
- : 'bg-white text-slate-400 hover:bg-slate-50'
- }`}
- >
-
- Upload Signed Doc
-
-
+
+
+ How did they confirm? *
+
+
+ {CHANNELS.map((c) => (
+
+ setConfirmedVia(c.value)}
+ />
+ {c.label}
+
+ ))}
+
+
- {signMethod === 'CAPTURE' ? (
-
-
- Hand the device to the customer to sign below
-
-
setSigData(null)}
+
+
+ Note (optional)
+
+
- ) : (
-
-
- Upload a photo or scan of the physically-signed estimate. This is recorded as the
- customer's consent proof.
+
+
+
+
+
+ Customer signature *
+
+
+
+
setSignMethod('CAPTURE')}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-[10px] font-black uppercase tracking-widest transition-all ${
+ signMethod === 'CAPTURE'
+ ? 'bg-slate-800 text-white'
+ : 'bg-white text-slate-400 hover:bg-slate-50'
+ }`}
+ >
+
+ Capture Signature
+
+
setSignMethod('UPLOAD')}
+ className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-[10px] font-black uppercase tracking-widest transition-all ${
+ signMethod === 'UPLOAD'
+ ? 'bg-slate-800 text-white'
+ : 'bg-white text-slate-400 hover:bg-slate-50'
+ }`}
+ >
+
+ Upload Signed Doc
+
-
fileInputRef.current?.click()}
- className="cursor-pointer rounded-xl border-2 border-dashed border-slate-200 p-3 text-center transition-all hover:border-slate-400 hover:bg-slate-50"
- >
- {uploadFile ? (
-
-
-
- {uploadFile.name}
-
-
- ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
-
+ {signMethod === 'CAPTURE' ? (
+
+
+ Hand the device to the customer to sign below
+
+
setSigData(null)}
+ />
+
+ ) : (
+
+
+ Upload a photo or scan of the physically-signed estimate. This is recorded as
+ the customer's consent proof.
- ) : (
- <>
-
-
Click to select file
-
JPG, PNG, PDF · max 15 MB
- >
- )}
-
-
{
- const f = e.target.files?.[0] ?? null;
- if (f && f.size > 15 * 1024 * 1024) {
- toast.error('File too large', { description: 'Maximum file size is 15 MB.' });
- return;
- }
- setUploadFile(f);
- }}
- />
-
-
- How was the signed copy obtained? *
-
-
+
fileInputRef.current?.click()}
+ className="cursor-pointer rounded-xl border-2 border-dashed border-slate-200 p-3 text-center transition-all hover:border-slate-400 hover:bg-slate-50"
+ >
+ {uploadFile ? (
+
+
+
+ {uploadFile.name}
+
+
+ ({(uploadFile.size / 1024 / 1024).toFixed(1)} MB)
+
+
+ ) : (
+ <>
+
+
Click to select file
+
+ JPG, PNG, PDF · max 15 MB
+
+ >
+ )}
+
+
{
+ const f = e.target.files?.[0] ?? null;
+ if (f && f.size > 15 * 1024 * 1024) {
+ toast.error('File too large', {
+ description: 'Maximum file size is 15 MB.',
+ });
+ return;
+ }
+ setUploadFile(f);
+ }}
+ />
+
+
+
+ How was the signed copy obtained? *
+
+
+
+ )}
- )}
+
-
+
-
+ {/* Sticky, because this form is long: the confirmation checkbox and the action it
+ gates were the two things furthest from each other, and staff had to scroll
+ back down to a button they had already passed. */}
+
Cancel
diff --git a/frontend/components/shared/ProductDetailModal.tsx b/frontend/components/shared/ProductDetailModal.tsx
index 795602f1..b1354e7c 100644
--- a/frontend/components/shared/ProductDetailModal.tsx
+++ b/frontend/components/shared/ProductDetailModal.tsx
@@ -3,7 +3,9 @@
import React, { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { getProductById, getAllProducts } from '@/lib/product';
+import { getServiceContracts, ServiceContract } from '@/lib/serviceContract';
import { resolveImageUrl } from '@/lib/imageUrl';
+import { getActiveCurrency } from '@/lib/currency';
import {
Loader2,
Package,
@@ -14,6 +16,7 @@ import {
Users,
Hash,
CheckCircle2,
+ FileText,
} from 'lucide-react';
interface ProductDetail {
@@ -122,6 +125,8 @@ export function ProductDetailModal({ productId, open, onClose, hideVendorDetails
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [availableQty, setAvailableQty] = useState(null);
+ const [contracts, setContracts] = useState([]);
+ const [contractsLoading, setContractsLoading] = useState(false);
useEffect(() => {
if (!open || !productId) return;
@@ -158,6 +163,22 @@ export function ProductDetailModal({ productId, open, onClose, hideVendorDetails
if (!cancelled) setLoading(false);
});
+ // Every AMC/SMA/FSMA agreement this machine has ever been under — current
+ // and expired/cancelled — regardless of which branch or customer it was
+ // under at the time. Silently empty for machines that never had one.
+ setContractsLoading(true);
+ setContracts([]);
+ getServiceContracts({ productId })
+ .then((list) => {
+ if (!cancelled) setContracts(list);
+ })
+ .catch(() => {
+ if (!cancelled) setContracts([]);
+ })
+ .finally(() => {
+ if (!cancelled) setContractsLoading(false);
+ });
+
return () => {
cancelled = true;
};
@@ -182,7 +203,10 @@ export function ProductDetailModal({ productId, open, onClose, hideVendorDetails
return (
!v && onClose()}>
-
+
Product Details
{/* ── White header ── */}
@@ -334,6 +358,66 @@ export function ProductDetailModal({ productId, open, onClose, hideVendorDetails
)}
+ {/* ── Service Agreement History ── */}
+ {(contractsLoading || contracts.length > 0) && (
+
+
+ {contractsLoading ? (
+
+
+
+ ) : (
+
+ {contracts.map((c) => {
+ const isCurrent = c.status === 'ACTIVE';
+ return (
+
+
+
+
+ {c.contractType}
+
+
+ {c.status}
+
+ {(c.customer?.firstName || c.customer?.lastName) && (
+
+ {[c.customer?.firstName, c.customer?.lastName]
+ .filter(Boolean)
+ .join(' ')}
+
+ )}
+
+ {c.contractType !== 'FSMA' && (
+
+ {getActiveCurrency()} {Number(c.contractValue).toFixed(2)}
+
+ )}
+
+
+ {fmtDate(c.startDate)} → {fmtDate(c.endDate)}
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
{/* ── Description ── */}
{product.description && (
diff --git a/frontend/components/ui/Modal.tsx b/frontend/components/ui/Modal.tsx
index ea8b80cb..53ba29ec 100644
--- a/frontend/components/ui/Modal.tsx
+++ b/frontend/components/ui/Modal.tsx
@@ -54,7 +54,15 @@ export function Modal({
}
}}
className={cn(
+ // A height budget and a scrolling body.
+ //
+ // This was `overflow-hidden` with no max-height, so a dialog taller than the
+ // screen simply grew past it: the title ran off the top, the confirm buttons
+ // off the bottom, and `overflow-hidden` clipped them rather than letting
+ // anyone scroll to them — the form became impossible to submit. Short
+ // dialogs never reach the cap and are unaffected.
'relative w-full bg-white rounded-xl shadow-xl p-6 overflow-hidden outline-none',
+ 'max-h-[calc(100vh-2rem)] flex flex-col',
'transition-all duration-150 ease-out',
'data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:scale-100 data-[state=open]:duration-150',
'data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:scale-95 data-[state=closed]:duration-150',
@@ -63,7 +71,7 @@ export function Modal({
)}
>
{title ? (
-
+
{title}
@@ -82,7 +90,10 @@ export function Modal({
)}
-
{children}
+ {/* min-h-0 is what lets this shrink inside the flex column; without it a flex
+ child refuses to go below its content height and the scrollbar never
+ appears. */}
+
{children}
diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts
index ef5f848a..4a3f59ee 100644
--- a/frontend/lib/auth.ts
+++ b/frontend/lib/auth.ts
@@ -192,3 +192,28 @@ export async function getProfile() {
const res = await api.get('/e/auth/me');
return res.data;
}
+
+/**
+ * Lists browsers/devices that can currently log in without OTP.
+ */
+export async function getTrustedDevices() {
+ const res = await api.get('/e/auth/trusted-devices');
+ return res.data;
+}
+
+/**
+ * Revokes a single trusted device — it will need OTP again on its next login.
+ * @param deviceId The ID of the trusted device to revoke
+ */
+export async function revokeTrustedDevice(deviceId: string) {
+ const res = await api.delete(`/e/auth/trusted-devices/${deviceId}`);
+ return res.data;
+}
+
+/**
+ * Revokes every trusted device for the current user (e.g. password compromised).
+ */
+export async function revokeAllTrustedDevices() {
+ const res = await api.post('/e/auth/revoke-trusted-devices');
+ return res.data;
+}
diff --git a/frontend/lib/invoice.ts b/frontend/lib/invoice.ts
index 3e44cfab..3981411a 100644
--- a/frontend/lib/invoice.ts
+++ b/frontend/lib/invoice.ts
@@ -1239,6 +1239,22 @@ export const getPendingServiceEstimates = async (): Promise
=> {
return response.data.data;
};
+/**
+ * Service estimates the customer has accepted, waiting for Accounts to take them into the
+ * books. Until that happens the job lives on a QUOTATION, and the accounting queries only
+ * count FINAL/PROFORMA — so the money is invisible to the ledger however much work is done.
+ */
+export const getCustomerAcceptedServiceEstimates = async (): Promise => {
+ const response = await api.get('/b/invoices?billType=SERVICE&status=CUSTOMER_ACCEPTED');
+ return response.data.data;
+};
+
+/** Accounts confirming one: QUOTATION/CUSTOMER_ACCEPTED → PROFORMA/INVOICED (QTN → INV). */
+export const confirmServiceEstimateToAccounts = async (id: string): Promise => {
+ const response = await api.post(`/b/invoices/${id}/confirm-service-to-accounts`);
+ return response.data.data;
+};
+
/** Finance-approved service estimates — ready to be sent to the customer. */
export const getApprovedServiceEstimates = async (): Promise => {
const response = await api.get('/b/invoices?billType=SERVICE&status=FINANCE_APPROVED');
diff --git a/frontend/lib/machineAllocations.ts b/frontend/lib/machineAllocations.ts
index 78c8f7ac..c85bc630 100644
--- a/frontend/lib/machineAllocations.ts
+++ b/frontend/lib/machineAllocations.ts
@@ -20,6 +20,7 @@ export interface HistoryAllocation {
currentColorA4?: number;
currentColorA3?: number;
warrantyInfo?: WarrantyInfo;
+ currentMeterReading?: number;
}
export interface HistoryInvoice {
@@ -133,6 +134,7 @@ export function getRentedMachines(
contractStatus: deriveContractStatus(inv.effectiveTo, inv.contractStatus),
contractReferenceId: inv.id,
invoiceNumber: inv.invoiceNumber,
+ meterReading: alloc.currentMeterReading,
type: 'RENT',
});
});
@@ -189,6 +191,7 @@ export function getLeasedMachines(
warrantyInfo: alloc.warrantyInfo,
contractReferenceId: inv.id,
invoiceNumber: inv.invoiceNumber,
+ meterReading: alloc.currentMeterReading,
type: 'LEASE',
});
});
@@ -261,6 +264,7 @@ export function getPurchasedMachines(
expiredFirst: w.expiredFirst,
effectiveTo: w.effectiveTo,
warrantyInfo: alloc.warrantyInfo,
+ meterReading: alloc.currentMeterReading,
type: 'SALE',
});
});
diff --git a/frontend/lib/payment.ts b/frontend/lib/payment.ts
index b2a0341d..80fa317c 100644
--- a/frontend/lib/payment.ts
+++ b/frontend/lib/payment.ts
@@ -29,6 +29,10 @@ export interface PaymentSummary {
totalAmount: number;
totalPaid: number;
pendingBalance: number;
+ /** Payment(s) already collected but sitting in the Accounts Receipts queue,
+ * awaiting Finance approval — not yet reflected in totalPaid/pendingBalance. */
+ pendingApprovalCount: number;
+ pendingApprovalAmount: number;
payments: PaymentLedger[];
status: string;
currencyWarnings?: string[];
diff --git a/frontend/lib/product.ts b/frontend/lib/product.ts
index 56dbf798..54c7277f 100644
--- a/frontend/lib/product.ts
+++ b/frontend/lib/product.ts
@@ -35,6 +35,9 @@ export interface Product {
ownership?: 'RENT' | 'LEASE' | 'SALE' | 'EXTERNAL';
meter_reading?: number;
customer_id?: string | null;
+ /** PRINTER (default, meter-based service) | COMPUTER | OTHER — only PRINTER
+ * uses meter readings and can take SMA/FSMA contracts. */
+ machine_type?: 'PRINTER' | 'COMPUTER' | 'OTHER';
warranty_start_date?: string;
warranty_end_date?: string;
warranty_max_pages?: number;
diff --git a/frontend/lib/saleWorkflow.ts b/frontend/lib/saleWorkflow.ts
index ff9563b2..97453101 100644
--- a/frontend/lib/saleWorkflow.ts
+++ b/frontend/lib/saleWorkflow.ts
@@ -471,7 +471,14 @@ export const recordSalePayment = async (
chequeDueDate?: string;
chequeDate?: string;
collectLater?: boolean;
- paymentContext?: 'SALE' | 'RENT_ADVANCE' | 'RENT_PERIODIC' | 'LEASE_ADVANCE' | 'LEASE_PERIODIC';
+ paymentContext?:
+ | 'SALE'
+ | 'RENT_ADVANCE'
+ | 'RENT_PERIODIC'
+ | 'LEASE_ADVANCE'
+ | 'LEASE_PERIODIC'
+ | 'SERVICE_CONTRACT_SIGNING'
+ | 'SERVICE_CONTRACT_INSTALLMENT';
isSecurityDeposit?: boolean;
// ONLINE_PAYMENT only. Card facts, never the PAN or the CVV — and deliberately no
diff --git a/frontend/lib/serviceContract.ts b/frontend/lib/serviceContract.ts
index a5a8a265..703c67c0 100644
--- a/frontend/lib/serviceContract.ts
+++ b/frontend/lib/serviceContract.ts
@@ -26,6 +26,9 @@ export interface ContractMachineInfo {
serialNumber: string;
ownership?: string | null;
meterReading?: number | null;
+ /** PRINTER (default) | COMPUTER | OTHER — only PRINTER uses meter readings and
+ * can take SMA/FSMA; COMPUTER/OTHER are AMC-only with no meter readings at all. */
+ machineType?: 'PRINTER' | 'COMPUTER' | 'OTHER';
}
export interface ContractTicketSummary {
@@ -176,9 +179,13 @@ export interface ExternalMachineInput {
brand: string;
modelName: string;
serialNumber: string;
+ /** Only meaningful for PRINTER — ignored (sent as 0) for COMPUTER/OTHER. */
meterReading: number;
printColour?: 'BLACK_WHITE' | 'COLOUR' | 'BOTH';
description?: string;
+ /** PRINTER (default) keeps meter-based service/SMA/FSMA; COMPUTER/OTHER have
+ * no usage meter and can only ever be enrolled in an AMC contract. */
+ machineType?: 'PRINTER' | 'COMPUTER' | 'OTHER';
}
export interface RegisteredExternalMachine {
@@ -189,6 +196,7 @@ export interface RegisteredExternalMachine {
ownership: string;
meter_reading?: number;
customer_id?: string | null;
+ machine_type?: 'PRINTER' | 'COMPUTER' | 'OTHER';
}
/** Registers a machine the customer bought elsewhere (ownership EXTERNAL). */
diff --git a/frontend/lib/serviceTicket.ts b/frontend/lib/serviceTicket.ts
index 0888a1de..ff343748 100644
--- a/frontend/lib/serviceTicket.ts
+++ b/frontend/lib/serviceTicket.ts
@@ -26,6 +26,8 @@ export interface ServiceTicketItem {
customPartName?: string;
customPartBrand?: string;
customPartDescription?: string;
+ /** CUSTOM (off-catalog) items only — technician-entered internal cost, since there's no catalog price to pull from. */
+ customPartCost?: number;
/** Brand of the part (from spare part record, or technician-entered for custom parts). */
partBrand?: string | null;
/** Manufacturer part number. */
@@ -35,6 +37,9 @@ export interface ServiceTicketItem {
unitPrice: number;
totalPrice: number;
isFree: boolean;
+ /** Real internal cost, even when unitPrice/totalPrice are 0 (FOC). Internal-only — never shown to the customer. */
+ unitCost?: number | null;
+ totalCost?: number | null;
}
export interface ServiceTicket {
@@ -74,6 +79,11 @@ export interface ServiceTicket {
visitChargeAmount?: number;
visitChargeMethod?: string | null;
visitChargeCollected?: boolean;
+ /** NONE | PENDING_APPROVAL | COLLECTED | REJECTED — see the ticket entity. */
+ visitChargeStatus?: 'NONE' | 'PENDING_APPROVAL' | 'COLLECTED' | 'REJECTED';
+ visitChargeCollectedByName?: string | null;
+ visitChargeCollectedByRole?: string | null;
+ visitChargeRejectionReason?: string | null;
transportChargeAmount?: number;
discountAmount?: number;
technicianNoteToFinance?: string | null;
@@ -100,7 +110,9 @@ export const getServiceTicketById = async (id: string): Promise =
};
export const createServiceTicket = async (data: Partial): Promise => {
- const response = await api.post('/i/service/tickets', data);
+ // The create dialog shows validation failures (e.g. "machine already has an
+ // open ticket") inline itself, so the global toast would just duplicate it.
+ const response = await api.post('/i/service/tickets', data, { skipErrorToast: true });
return response.data.data;
};
@@ -110,14 +122,35 @@ export const collectVisitCharge = async (
id: string,
paymentMode: string,
accountId?: string,
+ cheque?: { chequeNumber?: string; chequeBankName?: string; chequeDate?: string },
): Promise => {
const response = await api.post(`/i/service/tickets/${id}/collect-visit-charge`, {
paymentMode,
accountId,
+ ...(paymentMode === 'CHEQUE' ? cheque : {}),
});
return response.data.data;
};
+/** Collect a service ticket's completion balance any time after it's marked
+ * COMPLETED — "Not collected" at completion isn't final, the customer can
+ * still pay later. Goes to Accounts as a pending approval, same as every
+ * other collection path. */
+export const collectCompletionPayment = async (
+ id: string,
+ payload: {
+ amount: number;
+ paymentMode: string;
+ accountId?: string;
+ chequeNumber?: string;
+ chequeBankName?: string;
+ chequeDate?: string;
+ },
+): Promise<{ paymentRequestId: string; requestNo: string }> => {
+ const response = await api.post(`/i/service/tickets/${id}/collect-completion-payment`, payload);
+ return response.data.data;
+};
+
export const assignTechnician = async (
id: string,
assignedTechnicianId: string,
@@ -144,6 +177,7 @@ export const diagnoseServiceTicket = async (
visitChargeCollected?: boolean;
visitChargePaymentMode?: string;
visitChargeAccountId?: string;
+ visitChargeChequeNumber?: string;
transportChargeAmount?: number;
discountAmount?: number;
technicianNoteToFinance?: string | null;
@@ -231,6 +265,13 @@ export const completeServiceTicket = async (
technicianRemarks?: string;
customerSignature?: string;
technicianSignature?: string;
+ /** Payment the technician took at the door. Raised as a PENDING request for Accounts. */
+ collectedAmount?: number;
+ paymentMode?: string;
+ paymentAccountId?: string;
+ chequeNumber?: string;
+ chequeBankName?: string;
+ chequeDate?: string;
},
): Promise => {
const response = await api.post(`/i/service/tickets/${id}/complete`, payload);
@@ -528,6 +569,44 @@ export const getMachineYieldHistory = async (
return response.data.data;
};
+export interface MachineAnalyticsTicket {
+ ticketId: string;
+ ticketNumber: string;
+ date: string | null;
+ serviceContext: string;
+ partsUsed: Array<{
+ partName: string;
+ sku: string | null;
+ quantity: number;
+ unitCost: number;
+ totalCost: number;
+ isConsumable: boolean;
+ }>;
+ partsCostInternal: number;
+ labourCost: number;
+ totalSpend: number;
+}
+
+export interface MachineAnalytics {
+ serialNumber: string;
+ serviceVisitCount: number;
+ tickets: MachineAnalyticsTicket[];
+ toner: {
+ totalTonerReplacements: number;
+ replacementHistory: ConsumableYieldHistory[];
+ yieldHistory: ConsumableYieldHistory[];
+ };
+ lifetimePartsCost: number;
+ lifetimeLabourCost: number;
+ lifetimeSpend: number;
+}
+
+/** Real internal spend on a machine — works for company-owned AND external machines (keyed by serial, not productId). Internal-only, never shown to the customer. */
+export const getMachineAnalytics = async (serialNumber: string): Promise => {
+ const response = await api.get(`/i/service/machines/${serialNumber}/analytics`);
+ return response.data.data;
+};
+
export interface ServiceFinanceDashboard {
totalRevenue: number;
totalPartsCost: number;
diff --git a/frontend/public/quatationLayouts/productsalequatation/normal/productnormalqatation.tsx b/frontend/public/quatationLayouts/productsalequatation/normal/productnormalqatation.tsx
index d9d0c20d..e715000a 100644
--- a/frontend/public/quatationLayouts/productsalequatation/normal/productnormalqatation.tsx
+++ b/frontend/public/quatationLayouts/productsalequatation/normal/productnormalqatation.tsx
@@ -76,6 +76,8 @@ export interface ProductNormalQuotationProps {
payment: number;
balanceDue: number;
paid: boolean;
+ /** Collected but sitting in the Accounts approval queue — not yet in `payment`. */
+ pendingApprovalAmount?: number;
};
}
@@ -105,6 +107,7 @@ const tdStyle = (align: 'left' | 'center' | 'right' = 'center'): React.CSSProper
});
const ProductNormalQuotation: React.FC = ({
+ isInvoice,
warranty,
billTo = {
name: 'XEROCARE W. L. L',
@@ -641,19 +644,50 @@ const ProductNormalQuotation: React.FC = ({
));
})()}
- {totals.paid && (
-
- PAID
+ {isInvoice && totals.payment !== totals.total ? (
+
+
+
+ Amount Paid
+
+
+ {getActiveCurrency()} {fmt(totals.payment)}
+
+
+
+
+ Balance Due
+
+
+ {getActiveCurrency()} {fmt(totals.balanceDue)}
+
+
+ {!!totals.pendingApprovalAmount && (
+
+
+ Awaiting Finance Approval
+
+
+ {getActiveCurrency()} {fmt(totals.pendingApprovalAmount)}
+
+
+ )}
+ ) : (
+ totals.paid && (
+
+ PAID
+
+ )
)}
diff --git a/frontend/public/quatationLayouts/productsalequatation/premium/productpremiumquatation.tsx b/frontend/public/quatationLayouts/productsalequatation/premium/productpremiumquatation.tsx
index 933a51b7..1ea2ec03 100644
--- a/frontend/public/quatationLayouts/productsalequatation/premium/productpremiumquatation.tsx
+++ b/frontend/public/quatationLayouts/productsalequatation/premium/productpremiumquatation.tsx
@@ -64,6 +64,7 @@ export interface ProductPremiumQuotationProps {
payment: number;
balanceDue: number;
paid: boolean;
+ pendingApprovalAmount?: number;
};
}
@@ -784,7 +785,7 @@ const ProductPremiumQuotation: React.FC
= ({
Grand Total (Including {totals.vatName || 'VAT'}):
- {getActiveCurrency()} {fmt(totals.balanceDue)}
+ {getActiveCurrency()} {fmt(totals.total)}
= ({
marginTop: '1px',
}}
>
- {numberToWords(totals.balanceDue, getActiveCurrency())}
+ {numberToWords(totals.total, getActiveCurrency())}
- {totals.paid && (
-
- PAID
+ {totals.payment !== totals.total ? (
+
+
+ Amount Paid: {getActiveCurrency()} {fmt(totals.payment)}
+
+
+ Balance Due: {getActiveCurrency()} {fmt(totals.balanceDue)}
+
+ {!!totals.pendingApprovalAmount && (
+
+ Awaiting Finance Approval: {getActiveCurrency()}{' '}
+ {fmt(totals.pendingApprovalAmount)}
+
+ )}
+ ) : (
+ totals.paid && (
+
+ PAID
+
+ )
)}
diff --git a/frontend/public/quatationLayouts/productsalequatation/statnderd/productstatnderdquatation.tsx b/frontend/public/quatationLayouts/productsalequatation/statnderd/productstatnderdquatation.tsx
index a6718eef..a3535238 100644
--- a/frontend/public/quatationLayouts/productsalequatation/statnderd/productstatnderdquatation.tsx
+++ b/frontend/public/quatationLayouts/productsalequatation/statnderd/productstatnderdquatation.tsx
@@ -72,6 +72,8 @@ export interface ProductStandardQuotationProps {
payment: number;
balanceDue: number;
paid: boolean;
+ /** Collected but sitting in the Accounts approval queue — not yet in `payment`. */
+ pendingApprovalAmount?: number;
};
}
@@ -82,6 +84,7 @@ const fmt = (n: number) =>
n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const ProductStandardQuotation: React.FC = ({
+ isInvoice,
warranty,
productName = 'TONER CHIP SET',
modelName = 'ALTALink C8130',
@@ -776,19 +779,50 @@ const ProductStandardQuotation: React.FC = ({
{/* Grand Total */}
- {totals.paid && (
-
- PAID
+ {isInvoice && totals.payment !== totals.total ? (
+
+
+
+ Amount Paid
+
+
+ {getActiveCurrency()} {fmt(totals.payment)}
+
+
+
+
+ Balance Due
+
+
+ {getActiveCurrency()} {fmt(totals.balanceDue)}
+
+
+ {!!totals.pendingApprovalAmount && (
+
+
+ Awaiting Finance Approval
+
+
+ {getActiveCurrency()} {fmt(totals.pendingApprovalAmount)}
+
+
+ )}
+ ) : (
+ totals.paid && (
+
+ PAID
+
+ )
)}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c386e8e3..61559330 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -365,6 +365,9 @@ importers:
multer-s3:
specifier: ^3.0.1
version: 3.0.1(@aws-sdk/client-s3@3.966.0)
+ node-cron:
+ specifier: ^4.4.1
+ version: 4.4.1
nodemailer:
specifier: ^7.0.11
version: 7.0.12
@@ -414,6 +417,9 @@ importers:
'@types/node':
specifier: ^25.0.2
version: 25.0.6
+ '@types/node-cron':
+ specifier: ^3.0.11
+ version: 3.0.11
'@types/nodemailer':
specifier: ^7.0.4
version: 7.0.5
@@ -5174,6 +5180,7 @@ packages:
integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==,
}
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -12433,7 +12440,7 @@ snapshots:
'@next/eslint-plugin-next': 16.0.10
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
@@ -12460,7 +12467,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@5.5.0)
@@ -12475,14 +12482,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -12497,7 +12504,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3