- Add thermal/full-page receipt format toggle (per-device, localStorage) - Full-page receipt uses clean invoice layout matching repair PDF style - Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced) - Manager override system: configurable PIN prompt for void, refund, discount, cash in/out - Discount threshold setting: require manager approval above X% - Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals - Repair line item dialog: product picker dropdown for parts/consumables from inventory - Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods) - Transaction completion auto-updates repair ticket status to picked_up - POS Repairs dialog with Pickup and New Intake tabs, customer account lookup - Inline price adjustment on cart items: % off, $ off, or set price with live preview - Order-level discount button with same three input modes - Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint - Fix: backend dev script uses --env-file for turbo compatibility Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
import { eq, and, count, inArray, isNull, isNotNull, gte, lte, type Column, type SQL } from 'drizzle-orm'
|
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
|
import {
|
|
repairTickets,
|
|
repairLineItems,
|
|
repairBatches,
|
|
repairServiceTemplates,
|
|
repairNotes,
|
|
} from '../db/schema/repairs.js'
|
|
import type {
|
|
RepairTicketCreateInput,
|
|
RepairTicketUpdateInput,
|
|
RepairLineItemCreateInput,
|
|
RepairLineItemUpdateInput,
|
|
RepairBatchCreateInput,
|
|
RepairBatchUpdateInput,
|
|
RepairNoteCreateInput,
|
|
RepairServiceTemplateCreateInput,
|
|
RepairServiceTemplateUpdateInput,
|
|
PaginationInput,
|
|
} from '@lunarfront/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,
|
|
): 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(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>, input: RepairTicketCreateInput) {
|
|
const ticketNumber = await generateUniqueNumber(
|
|
db, repairTickets, repairTickets.ticketNumber,
|
|
)
|
|
|
|
const [ticket] = await db
|
|
.insert(repairTickets)
|
|
.values({
|
|
ticketNumber,
|
|
customerName: input.customerName,
|
|
customerPhone: input.customerPhone,
|
|
accountId: input.accountId,
|
|
locationId: input.locationId,
|
|
repairBatchId: input.repairBatchId,
|
|
inventoryUnitId: input.inventoryUnitId,
|
|
itemDescription: input.itemDescription,
|
|
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>, id: string) {
|
|
const [ticket] = await db
|
|
.select()
|
|
.from(repairTickets)
|
|
.where(eq(repairTickets.id, id))
|
|
.limit(1)
|
|
return ticket ?? null
|
|
},
|
|
|
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
|
|
status?: string[]
|
|
conditionIn?: string[]
|
|
isBatch?: boolean
|
|
batchNumber?: string
|
|
intakeDateFrom?: string
|
|
intakeDateTo?: string
|
|
promisedDateFrom?: string
|
|
promisedDateTo?: string
|
|
completedDateFrom?: string
|
|
completedDateTo?: string
|
|
}) {
|
|
const conditions: SQL[] = []
|
|
|
|
if (params.q) {
|
|
const search = buildSearchCondition(params.q, [
|
|
repairTickets.ticketNumber,
|
|
repairTickets.customerName,
|
|
repairTickets.customerPhone,
|
|
repairTickets.itemDescription,
|
|
repairTickets.serialNumber,
|
|
])
|
|
if (search) conditions.push(search)
|
|
}
|
|
|
|
if (filters?.status?.length) {
|
|
conditions.push(inArray(repairTickets.status, filters.status as any))
|
|
}
|
|
if (filters?.conditionIn?.length) {
|
|
conditions.push(inArray(repairTickets.conditionIn, filters.conditionIn as any))
|
|
}
|
|
if (filters?.isBatch === true) {
|
|
conditions.push(isNotNull(repairTickets.repairBatchId))
|
|
} else if (filters?.isBatch === false) {
|
|
conditions.push(isNull(repairTickets.repairBatchId))
|
|
}
|
|
if (filters?.intakeDateFrom) conditions.push(gte(repairTickets.intakeDate, new Date(filters.intakeDateFrom)))
|
|
if (filters?.intakeDateTo) conditions.push(lte(repairTickets.intakeDate, new Date(filters.intakeDateTo)))
|
|
if (filters?.promisedDateFrom) conditions.push(gte(repairTickets.promisedDate, new Date(filters.promisedDateFrom)))
|
|
if (filters?.promisedDateTo) conditions.push(lte(repairTickets.promisedDate, new Date(filters.promisedDateTo)))
|
|
if (filters?.completedDateFrom) conditions.push(gte(repairTickets.completedDate, new Date(filters.completedDateFrom)))
|
|
if (filters?.completedDateTo) conditions.push(lte(repairTickets.completedDate, new Date(filters.completedDateTo)))
|
|
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined
|
|
|
|
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>, batchId: string, params: PaginationInput) {
|
|
const baseWhere = eq(repairTickets.repairBatchId, batchId)
|
|
const searchCondition = params.q
|
|
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.itemDescription])
|
|
: 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 listReadyForPickup(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
|
const baseWhere = eq(repairTickets.status, 'ready')
|
|
const searchCondition = params.q
|
|
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.customerPhone])
|
|
: undefined
|
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
|
|
|
let query = db.select().from(repairTickets).where(where).$dynamic()
|
|
query = withSort(query, params.sort, params.order, { ticket_number: repairTickets.ticketNumber, customer_name: repairTickets.customerName, created_at: repairTickets.createdAt }, 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>, 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(eq(repairTickets.id, id))
|
|
.returning()
|
|
return ticket ?? null
|
|
},
|
|
|
|
async updateStatus(db: PostgresJsDatabase<any>, 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(eq(repairTickets.id, id))
|
|
.returning()
|
|
return ticket ?? null
|
|
},
|
|
|
|
async delete(db: PostgresJsDatabase<any>, 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(eq(repairTickets.id, id))
|
|
.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>, input: RepairBatchCreateInput) {
|
|
const batchNumber = await generateUniqueNumber(
|
|
db, repairBatches, repairBatches.batchNumber,
|
|
)
|
|
|
|
const [batch] = await db
|
|
.insert(repairBatches)
|
|
.values({
|
|
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,
|
|
itemCount: input.itemCount,
|
|
notes: input.notes,
|
|
})
|
|
.returning()
|
|
return batch
|
|
},
|
|
|
|
async getById(db: PostgresJsDatabase<any>, id: string) {
|
|
const [batch] = await db
|
|
.select()
|
|
.from(repairBatches)
|
|
.where(eq(repairBatches.id, id))
|
|
.limit(1)
|
|
return batch ?? null
|
|
},
|
|
|
|
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
|
|
const searchCondition = params.q
|
|
? buildSearchCondition(params.q, [repairBatches.batchNumber, repairBatches.contactName, repairBatches.contactEmail])
|
|
: undefined
|
|
|
|
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(searchCondition).$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(searchCondition),
|
|
])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
|
|
async update(db: PostgresJsDatabase<any>, 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(eq(repairBatches.id, id))
|
|
.returning()
|
|
return batch ?? null
|
|
},
|
|
|
|
async updateStatus(db: PostgresJsDatabase<any>, 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(eq(repairBatches.id, id))
|
|
.returning()
|
|
return batch ?? null
|
|
},
|
|
|
|
async approve(db: PostgresJsDatabase<any>, id: string, approvedBy: string) {
|
|
const [batch] = await db
|
|
.update(repairBatches)
|
|
.set({
|
|
approvalStatus: 'approved',
|
|
approvedBy,
|
|
approvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(repairBatches.id, id))
|
|
.returning()
|
|
return batch ?? null
|
|
},
|
|
|
|
async reject(db: PostgresJsDatabase<any>, id: string) {
|
|
const [batch] = await db
|
|
.update(repairBatches)
|
|
.set({
|
|
approvalStatus: 'rejected',
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(repairBatches.id, id))
|
|
.returning()
|
|
return batch ?? null
|
|
},
|
|
}
|
|
|
|
export const RepairServiceTemplateService = {
|
|
async create(db: PostgresJsDatabase<any>, input: RepairServiceTemplateCreateInput) {
|
|
const [template] = await db
|
|
.insert(repairServiceTemplates)
|
|
.values({
|
|
name: input.name,
|
|
itemCategory: input.itemCategory,
|
|
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>, params: PaginationInput) {
|
|
const baseWhere = eq(repairServiceTemplates.isActive, true)
|
|
const searchCondition = params.q
|
|
? buildSearchCondition(params.q, [repairServiceTemplates.name, repairServiceTemplates.itemCategory, repairServiceTemplates.size, repairServiceTemplates.description])
|
|
: undefined
|
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
|
|
|
const sortableColumns: Record<string, Column> = {
|
|
name: repairServiceTemplates.name,
|
|
item_category: repairServiceTemplates.itemCategory,
|
|
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>, 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(eq(repairServiceTemplates.id, id))
|
|
.returning()
|
|
return template ?? null
|
|
},
|
|
|
|
async delete(db: PostgresJsDatabase<any>, id: string) {
|
|
const [template] = await db
|
|
.update(repairServiceTemplates)
|
|
.set({ isActive: false, updatedAt: new Date() })
|
|
.where(eq(repairServiceTemplates.id, id))
|
|
.returning()
|
|
return template ?? null
|
|
},
|
|
}
|
|
|
|
export const RepairNoteService = {
|
|
async create(db: PostgresJsDatabase<any>, ticketId: string, authorId: string, authorName: string, ticketStatus: string, input: RepairNoteCreateInput) {
|
|
const [note] = await db
|
|
.insert(repairNotes)
|
|
.values({
|
|
repairTicketId: ticketId,
|
|
authorId,
|
|
authorName,
|
|
content: input.content,
|
|
visibility: input.visibility,
|
|
ticketStatus: ticketStatus as any,
|
|
})
|
|
.returning()
|
|
return note
|
|
},
|
|
|
|
async listByTicket(db: PostgresJsDatabase<any>, ticketId: string, params: PaginationInput) {
|
|
const baseWhere = eq(repairNotes.repairTicketId, ticketId)
|
|
const searchCondition = params.q
|
|
? buildSearchCondition(params.q, [repairNotes.content, repairNotes.authorName])
|
|
: undefined
|
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
|
|
|
const sortableColumns: Record<string, Column> = {
|
|
created_at: repairNotes.createdAt,
|
|
author_name: repairNotes.authorName,
|
|
}
|
|
|
|
let query = db.select().from(repairNotes).where(where).$dynamic()
|
|
query = withSort(query, params.sort, params.order, sortableColumns, repairNotes.createdAt)
|
|
query = withPagination(query, params.page, params.limit)
|
|
|
|
const [data, [{ total }]] = await Promise.all([
|
|
query,
|
|
db.select({ total: count() }).from(repairNotes).where(where),
|
|
])
|
|
|
|
return paginatedResponse(data, total, params.page, params.limit)
|
|
},
|
|
|
|
async delete(db: PostgresJsDatabase<any>, id: string) {
|
|
const [note] = await db
|
|
.delete(repairNotes)
|
|
.where(eq(repairNotes.id, id))
|
|
.returning()
|
|
return note ?? null
|
|
},
|
|
}
|