Add repairs domain with tickets, line items, batches, and service templates

Full-stack implementation of instrument repair tracking: DB schema with
repair_ticket, repair_line_item, repair_batch, and repair_service_template
tables. Backend services and routes with pagination/search/sort. 20 API
tests covering CRUD, status workflow, line items, and batch operations.
Admin frontend with ticket list, detail with status progression, line item
management, batch list/detail with approval workflow, and new ticket form
with searchable account picker and intake photo uploads.
This commit is contained in:
Ryan Moon
2026-03-29 09:12:40 -05:00
parent 1d48f0befa
commit f17bbff02c
20 changed files with 2791 additions and 1 deletions

View File

@@ -0,0 +1,427 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
repairTickets,
repairLineItems,
repairBatches,
repairServiceTemplates,
} from '../db/schema/repairs.js'
import type {
RepairTicketCreateInput,
RepairTicketUpdateInput,
RepairLineItemCreateInput,
RepairLineItemUpdateInput,
RepairBatchCreateInput,
RepairBatchUpdateInput,
RepairServiceTemplateCreateInput,
RepairServiceTemplateUpdateInput,
PaginationInput,
} from '@forte/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
async function generateUniqueNumber(
db: PostgresJsDatabase<any>,
table: typeof repairTickets | typeof repairBatches,
column: typeof repairTickets.ticketNumber | typeof repairBatches.batchNumber,
companyId: string,
companyIdColumn: Column,
): Promise<string> {
for (let attempt = 0; attempt < 10; attempt++) {
const num = String(Math.floor(100000 + Math.random() * 900000))
const [existing] = await db
.select({ id: table.id })
.from(table)
.where(and(eq(companyIdColumn, companyId), eq(column, num)))
.limit(1)
if (!existing) return num
}
return String(Math.floor(10000000 + Math.random() * 90000000))
}
export const RepairTicketService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairTicketCreateInput) {
const ticketNumber = await generateUniqueNumber(
db, repairTickets, repairTickets.ticketNumber, companyId, repairTickets.companyId,
)
const [ticket] = await db
.insert(repairTickets)
.values({
companyId,
ticketNumber,
customerName: input.customerName,
customerPhone: input.customerPhone,
accountId: input.accountId,
locationId: input.locationId,
repairBatchId: input.repairBatchId,
inventoryUnitId: input.inventoryUnitId,
instrumentDescription: input.instrumentDescription,
serialNumber: input.serialNumber,
conditionIn: input.conditionIn,
conditionInNotes: input.conditionInNotes,
problemDescription: input.problemDescription,
technicianNotes: input.technicianNotes,
assignedTechnicianId: input.assignedTechnicianId,
estimatedCost: input.estimatedCost?.toString(),
promisedDate: input.promisedDate ? new Date(input.promisedDate) : undefined,
})
.returning()
return ticket
},
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [ticket] = await db
.select()
.from(repairTickets)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId)))
.limit(1)
return ticket ?? null
},
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = eq(repairTickets.companyId, companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [
repairTickets.ticketNumber,
repairTickets.customerName,
repairTickets.customerPhone,
repairTickets.instrumentDescription,
repairTickets.serialNumber,
])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
ticket_number: repairTickets.ticketNumber,
customer_name: repairTickets.customerName,
status: repairTickets.status,
intake_date: repairTickets.intakeDate,
promised_date: repairTickets.promisedDate,
created_at: repairTickets.createdAt,
}
let query = db.select().from(repairTickets).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairTickets.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairTickets).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listByBatch(db: PostgresJsDatabase<any>, companyId: string, batchId: string, params: PaginationInput) {
const baseWhere = and(eq(repairTickets.companyId, companyId), eq(repairTickets.repairBatchId, batchId))
const searchCondition = params.q
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.instrumentDescription])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
ticket_number: repairTickets.ticketNumber,
customer_name: repairTickets.customerName,
status: repairTickets.status,
created_at: repairTickets.createdAt,
}
let query = db.select().from(repairTickets).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairTickets.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairTickets).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairTicketUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString()
if (input.promisedDate !== undefined) values.promisedDate = input.promisedDate ? new Date(input.promisedDate) : null
const [ticket] = await db
.update(repairTickets)
.set(values)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId)))
.returning()
return ticket ?? null
},
async updateStatus(db: PostgresJsDatabase<any>, companyId: string, id: string, status: string) {
const updates: Record<string, unknown> = { status, updatedAt: new Date() }
if (status === 'ready' || status === 'picked_up' || status === 'delivered') {
updates.completedDate = new Date()
}
const [ticket] = await db
.update(repairTickets)
.set(updates)
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId)))
.returning()
return ticket ?? null
},
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
// Soft-cancel: set status to cancelled rather than hard delete
const [ticket] = await db
.update(repairTickets)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(and(eq(repairTickets.id, id), eq(repairTickets.companyId, companyId)))
.returning()
return ticket ?? null
},
}
export const RepairLineItemService = {
async create(db: PostgresJsDatabase<any>, input: RepairLineItemCreateInput) {
const [item] = await db
.insert(repairLineItems)
.values({
repairTicketId: input.repairTicketId,
itemType: input.itemType,
description: input.description,
productId: input.productId,
qty: input.qty?.toString(),
unitPrice: input.unitPrice?.toString(),
totalPrice: input.totalPrice?.toString(),
cost: input.cost?.toString(),
technicianId: input.technicianId,
})
.returning()
return item
},
async listByTicket(db: PostgresJsDatabase<any>, ticketId: string, params: PaginationInput) {
const where = eq(repairLineItems.repairTicketId, ticketId)
const sortableColumns: Record<string, Column> = {
item_type: repairLineItems.itemType,
created_at: repairLineItems.createdAt,
}
let query = db.select().from(repairLineItems).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairLineItems.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairLineItems).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: RepairLineItemUpdateInput) {
const values: Record<string, unknown> = { ...input }
if (input.qty !== undefined) values.qty = input.qty.toString()
if (input.unitPrice !== undefined) values.unitPrice = input.unitPrice.toString()
if (input.totalPrice !== undefined) values.totalPrice = input.totalPrice.toString()
if (input.cost !== undefined) values.cost = input.cost.toString()
const [item] = await db
.update(repairLineItems)
.set(values)
.where(eq(repairLineItems.id, id))
.returning()
return item ?? null
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [item] = await db
.delete(repairLineItems)
.where(eq(repairLineItems.id, id))
.returning()
return item ?? null
},
}
export const RepairBatchService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairBatchCreateInput) {
const batchNumber = await generateUniqueNumber(
db, repairBatches, repairBatches.batchNumber, companyId, repairBatches.companyId,
)
const [batch] = await db
.insert(repairBatches)
.values({
companyId,
batchNumber,
accountId: input.accountId,
locationId: input.locationId,
contactName: input.contactName,
contactPhone: input.contactPhone,
contactEmail: input.contactEmail,
pickupDate: input.pickupDate ? new Date(input.pickupDate) : undefined,
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
instrumentCount: input.instrumentCount,
notes: input.notes,
})
.returning()
return batch
},
async getById(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [batch] = await db
.select()
.from(repairBatches)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId)))
.limit(1)
return batch ?? null
},
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = eq(repairBatches.companyId, companyId)
const searchCondition = params.q
? buildSearchCondition(params.q, [repairBatches.batchNumber, repairBatches.contactName, repairBatches.contactEmail])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
batch_number: repairBatches.batchNumber,
status: repairBatches.status,
due_date: repairBatches.dueDate,
created_at: repairBatches.createdAt,
}
let query = db.select().from(repairBatches).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairBatches.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairBatches).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairBatchUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.pickupDate !== undefined) values.pickupDate = input.pickupDate ? new Date(input.pickupDate) : null
if (input.dueDate !== undefined) values.dueDate = input.dueDate ? new Date(input.dueDate) : null
const [batch] = await db
.update(repairBatches)
.set(values)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId)))
.returning()
return batch ?? null
},
async updateStatus(db: PostgresJsDatabase<any>, companyId: string, id: string, status: string) {
const updates: Record<string, unknown> = { status, updatedAt: new Date() }
if (status === 'completed') updates.completedDate = new Date()
if (status === 'delivered') updates.deliveredDate = new Date()
const [batch] = await db
.update(repairBatches)
.set(updates)
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId)))
.returning()
return batch ?? null
},
async approve(db: PostgresJsDatabase<any>, companyId: string, id: string, approvedBy: string) {
const [batch] = await db
.update(repairBatches)
.set({
approvalStatus: 'approved',
approvedBy,
approvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId)))
.returning()
return batch ?? null
},
async reject(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [batch] = await db
.update(repairBatches)
.set({
approvalStatus: 'rejected',
updatedAt: new Date(),
})
.where(and(eq(repairBatches.id, id), eq(repairBatches.companyId, companyId)))
.returning()
return batch ?? null
},
}
export const RepairServiceTemplateService = {
async create(db: PostgresJsDatabase<any>, companyId: string, input: RepairServiceTemplateCreateInput) {
const [template] = await db
.insert(repairServiceTemplates)
.values({
companyId,
name: input.name,
instrumentType: input.instrumentType,
size: input.size,
description: input.description,
itemType: input.itemType,
defaultPrice: input.defaultPrice?.toString(),
defaultCost: input.defaultCost?.toString(),
sortOrder: input.sortOrder,
})
.returning()
return template
},
async list(db: PostgresJsDatabase<any>, companyId: string, params: PaginationInput) {
const baseWhere = and(eq(repairServiceTemplates.companyId, companyId), eq(repairServiceTemplates.isActive, true))
const searchCondition = params.q
? buildSearchCondition(params.q, [repairServiceTemplates.name, repairServiceTemplates.instrumentType, repairServiceTemplates.size, repairServiceTemplates.description])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
const sortableColumns: Record<string, Column> = {
name: repairServiceTemplates.name,
instrument_type: repairServiceTemplates.instrumentType,
default_price: repairServiceTemplates.defaultPrice,
sort_order: repairServiceTemplates.sortOrder,
created_at: repairServiceTemplates.createdAt,
}
let query = db.select().from(repairServiceTemplates).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, repairServiceTemplates.sortOrder)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairServiceTemplates).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, companyId: string, id: string, input: RepairServiceTemplateUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.defaultPrice !== undefined) values.defaultPrice = input.defaultPrice.toString()
if (input.defaultCost !== undefined) values.defaultCost = input.defaultCost.toString()
const [template] = await db
.update(repairServiceTemplates)
.set(values)
.where(and(eq(repairServiceTemplates.id, id), eq(repairServiceTemplates.companyId, companyId)))
.returning()
return template ?? null
},
async delete(db: PostgresJsDatabase<any>, companyId: string, id: string) {
const [template] = await db
.update(repairServiceTemplates)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(repairServiceTemplates.id, id), eq(repairServiceTemplates.companyId, companyId)))
.returning()
return template ?? null
},
}