Fe changes
This commit is contained in:
@@ -12,6 +12,8 @@ import type {
|
||||
RouteVisitResponse,
|
||||
VehicleResponse,
|
||||
WarehouseResponse,
|
||||
WarehouseDashboardResponse,
|
||||
VehicleDashboardResponse,
|
||||
VehicleType,
|
||||
WarehouseType,
|
||||
} from '@/types/api'
|
||||
@@ -199,6 +201,10 @@ export const vehicles = {
|
||||
get: (id: string) =>
|
||||
api.get<VehicleResponse>(`/vehicles/${id}`).then(r => normalizeVehicle(r.data)),
|
||||
|
||||
dashboard: (id: string, params?: { from?: string; to?: string }) =>
|
||||
api.get<VehicleDashboardResponse>(`/vehicles/${id}/dashboard`, { params })
|
||||
.then(r => ({ ...r.data, vehicle: normalizeVehicle(r.data.vehicle) })),
|
||||
|
||||
create: (body: VehicleBody) =>
|
||||
api.post<VehicleResponse>('/vehicles', serializeVehicleBody(body)).then(r => normalizeVehicle(r.data)),
|
||||
|
||||
@@ -226,6 +232,9 @@ export const warehouses = {
|
||||
get: (id: string) =>
|
||||
api.get<WarehouseResponse>(`/warehouses/${id}`).then(r => r.data),
|
||||
|
||||
dashboard: (id: string, params?: { from?: string; to?: string; deadStockDays?: number }) =>
|
||||
api.get<WarehouseDashboardResponse>(`/warehouses/${id}/dashboard`, { params }).then(r => r.data),
|
||||
|
||||
create: (body: WarehouseBody) =>
|
||||
api.post<WarehouseResponse>('/warehouses', body).then(r => r.data),
|
||||
|
||||
|
||||
@@ -72,6 +72,80 @@ export interface ProductStockParams extends ProductReportParams {
|
||||
lowStockOnly?: boolean
|
||||
}
|
||||
|
||||
export interface StockOverview {
|
||||
totalSkus: number
|
||||
totalQty: number
|
||||
totalValue: number
|
||||
lowStockCount: number
|
||||
outOfStockCount: number
|
||||
expiringSoonCount: number
|
||||
expiredCount: number
|
||||
}
|
||||
|
||||
export type StockLedgerStatus = 'OK' | 'Low' | 'Out'
|
||||
|
||||
export interface StockLedgerRow {
|
||||
itemId: string
|
||||
sku: string
|
||||
name: string
|
||||
brand: string | null
|
||||
category: string
|
||||
uom: string
|
||||
openingQty: number
|
||||
qtyIn: number
|
||||
qtyOut: number
|
||||
closingQty: number
|
||||
currentQty: number
|
||||
costPrice: number | null
|
||||
stockValue: number
|
||||
reorderLevel: number | null
|
||||
status: StockLedgerStatus
|
||||
}
|
||||
|
||||
export interface StockExpiryRow {
|
||||
itemId: string
|
||||
sku: string
|
||||
name: string
|
||||
batchId: string
|
||||
batchNumber: string | null
|
||||
expiryDate: string
|
||||
daysLeft: number
|
||||
quantity: number
|
||||
costPrice: number | null
|
||||
value: number
|
||||
locationId: string
|
||||
locationType: string
|
||||
locationName: string
|
||||
}
|
||||
|
||||
export interface StockLedgerParams extends PageParams {
|
||||
locationId?: string
|
||||
categoryId?: string
|
||||
brandId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
status?: 'low' | 'out'
|
||||
activeOnly?: boolean
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface StockExpiryParams extends PageParams {
|
||||
locationId?: string
|
||||
days?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
export const stockReports = {
|
||||
overview: (params?: { locationId?: string; expiryDays?: number }) =>
|
||||
api.get<StockOverview>('/reports/stock/overview', { params }).then(r => r.data),
|
||||
|
||||
ledger: (params?: StockLedgerParams) =>
|
||||
api.get<PaginatedResponse<StockLedgerRow>>('/reports/stock/ledger', { params }).then(r => r.data),
|
||||
|
||||
expiry: (params?: StockExpiryParams) =>
|
||||
api.get<PaginatedResponse<StockExpiryRow>>('/reports/stock/expiry', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const productReports = {
|
||||
stock: (params?: ProductStockParams) =>
|
||||
api.get<PaginatedResponse<ProductStockRow>>('/reports/products/stock', { params }).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface ExportColumn<T> {
|
||||
header: string
|
||||
value: (row: T) => string | number | null | undefined
|
||||
/** Right-align and format as a number in Excel. */
|
||||
numeric?: boolean
|
||||
}
|
||||
|
||||
const MAX_PAGE_SIZE = 200
|
||||
|
||||
/** Walks every page of a server-paged endpoint and returns all rows. */
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>,
|
||||
): Promise<T[]> {
|
||||
const rows: T[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await fetchPage(page, MAX_PAGE_SIZE)
|
||||
rows.push(...res.items)
|
||||
if (res.items.length === 0 || rows.length >= res.total) return rows
|
||||
}
|
||||
}
|
||||
|
||||
function stamp() {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function download(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function exportExcel<T>(name: string, columns: ExportColumn<T>[], rows: T[]) {
|
||||
const { Workbook } = await import('exceljs')
|
||||
const wb = new Workbook()
|
||||
const ws = wb.addWorksheet(name.slice(0, 31))
|
||||
|
||||
ws.columns = columns.map(c => ({ header: c.header, width: Math.max(c.header.length + 4, 14) }))
|
||||
ws.getRow(1).font = { bold: true }
|
||||
ws.views = [{ state: 'frozen', ySplit: 1 }]
|
||||
|
||||
rows.forEach(r => ws.addRow(columns.map(c => c.value(r) ?? '')))
|
||||
columns.forEach((c, i) => {
|
||||
if (c.numeric) {
|
||||
const col = ws.getColumn(i + 1)
|
||||
col.numFmt = '#,##0.##'
|
||||
col.alignment = { horizontal: 'right' }
|
||||
}
|
||||
})
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer()
|
||||
download(
|
||||
new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
`${name}-${stamp()}.xlsx`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function exportPdf<T>(name: string, title: string, columns: ExportColumn<T>[], rows: T[], subtitle?: string) {
|
||||
const [{ jsPDF }, { default: autoTable }] = await Promise.all([import('jspdf'), import('jspdf-autotable')])
|
||||
const doc = new jsPDF({ orientation: columns.length > 6 ? 'landscape' : 'portrait', unit: 'pt', format: 'a4' })
|
||||
|
||||
doc.setFontSize(14)
|
||||
doc.text(title, 40, 40)
|
||||
doc.setFontSize(9)
|
||||
doc.text(subtitle ? `${subtitle} · Generated ${stamp()}` : `Generated ${stamp()}`, 40, 56)
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 68,
|
||||
head: [columns.map(c => c.header)],
|
||||
body: rows.map(r => columns.map(c => String(c.value(r) ?? ''))),
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [55, 65, 81] },
|
||||
columnStyles: Object.fromEntries(
|
||||
columns.map((c, i) => [i, c.numeric ? { halign: 'right' as const } : {}]),
|
||||
),
|
||||
})
|
||||
|
||||
doc.save(`${name}-${stamp()}.pdf`)
|
||||
}
|
||||
Reference in New Issue
Block a user