product and sales fixers

This commit is contained in:
2026-09-30 17:06:28 +05:30
parent 9fd290e256
commit cea433e320
98 changed files with 85367 additions and 1516 deletions
+26
View File
@@ -5,10 +5,12 @@ import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
import {
CreateItemRequest,
Item,
ItemImage,
ItemListItem,
ItemOverview,
ItemReorderSetting,
ItemScanResult,
StockNature,
TrackingMode,
UpdateItemReorderRequest,
UpdateItemRequest,
@@ -34,8 +36,13 @@ export interface ListItemOverviewParams {
categoryId?: number
subCategoryId?: number
brandId?: number
trackingMode?: TrackingMode
stockNature?: StockNature
stock?: ItemStockFilter
}
export type ItemStockFilter = "InStock" | "OutOfStock" | "LowStock"
export const itemsApi = {
list(params: ListItemsParams = {}): Promise<PagedResponse<ItemListItem>> {
return apiRequest<PagedResponse<ItemListItem>>(`/items${buildQuery(params)}`)
@@ -46,6 +53,25 @@ export const itemsApi = {
overview(params: ListItemOverviewParams = {}): Promise<PagedResponse<ItemOverview>> {
return apiRequest<PagedResponse<ItemOverview>>(`/items/overview${buildQuery(params)}`)
},
listImages(itemId: number): Promise<ItemImage[]> {
return apiRequest<ItemImage[]>(`/items/${itemId}/images`)
},
/** JPG/PNG/WebP, ≤ 5 MB, ≤ 10 per item. 422 CONFIG_DISABLED while item images are off. */
uploadImage(itemId: number, file: File): Promise<ItemImage> {
const form = new FormData()
form.append("file", file)
return apiRequest<ItemImage>(`/items/${itemId}/images`, { method: "POST", body: form })
},
deleteImage(itemId: number, itemImageId: number): Promise<void> {
return apiRequest<void>(`/items/${itemId}/images/${itemImageId}`, { method: "DELETE" })
},
/** Same-origin and cookie-authenticated, so it works directly as an <img> src. */
imageUrl(itemId: number, itemImageId: number): string {
return `/api/v1/items/${itemId}/images/${itemImageId}/content`
},
get(itemId: number): Promise<ApiResult<Item>> {
return apiRequestWithETag<Item>(`/items/${itemId}`)
@@ -16,8 +16,7 @@ export const productConfigApi = {
/** All flags are required; a partial body is a 400, never a silent disable. */
update(request: UpdateProductConfigRequest, ifMatch: string): Promise<ApiResult<ProductConfig>> {
return apiRequestWithETag<ProductConfig>("/product-config", { method: "PUT", body: request, ifMatch })
},
}
},}
/** Read-only helper for screens that only need the flags. */
export function productConfig(): Promise<ProductConfig> {
@@ -0,0 +1,16 @@
// Sales configuration. Singleton - no id. When `allowCashCustomer` is on, sales can be raised
// against the generic "Cash Customer" (`cashCustomerId`) and New Sale defaults to it; when off,
// a real customer must be picked (the server rejects the cash customer).
import { apiRequestWithETag } from "@/lib/api-client"
import { ApiResult } from "@/types/common"
import { SalesConfig, UpdateSalesConfigRequest } from "@/types/sales"
export const salesConfigApi = {
get(): Promise<ApiResult<SalesConfig>> {
return apiRequestWithETag<SalesConfig>("/sales-config")
},
update(request: UpdateSalesConfigRequest, ifMatch: string): Promise<ApiResult<SalesConfig>> {
return apiRequestWithETag<SalesConfig>("/sales-config", { method: "PUT", body: request, ifMatch })
},
}
+12
View File
@@ -20,6 +20,8 @@ import {
SalesSlipStatus,
SalesSlipPostingCheck,
SalesSlipSummary,
SalesStockAvailability,
SalesSuggestedPrice,
UpdateSalesInvoiceRequest,
UpdateSalesSlipRequest,
} from "@/types/sales"
@@ -46,6 +48,16 @@ export const salesApi = {
return apiRequest<PagedResponse<SalesInvoiceSummary>>(`/sales-invoices${buildQuery(params)}`)
},
/** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */
getSuggestedPrice(itemId: number, warehouseId: number): Promise<SalesSuggestedPrice> {
return apiRequest<SalesSuggestedPrice>(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`)
},
/** Issuable stock for an item at a warehouse — New Sale's per-line shortage warning. */
getStockAvailability(itemId: number, warehouseId: number): Promise<SalesStockAvailability> {
return apiRequest<SalesStockAvailability>(`/sales-invoices/stock-availability${buildQuery({ itemId, warehouseId })}`)
},
getInvoice(salesInvoiceId: number): Promise<ApiResult<SalesInvoice>> {
return apiRequestWithETag<SalesInvoice>(`/sales-invoices/${salesInvoiceId}`)
},
@@ -0,0 +1,21 @@
// Custom warranty period master (Products → Warranty Periods). Defaults (3/6/12/18 months)
// are fixed client+server side and not returned here.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { EntityStatus } from "@/types/common"
import { WarrantyPeriod } from "@/types/master-data"
export const warrantyPeriodsApi = {
list(params: { status?: EntityStatus } = {}): Promise<WarrantyPeriod[]> {
return apiRequest<WarrantyPeriod[]>(`/warranty-periods${buildQuery(params)}`)
},
/** Months 1–600, not a default. Re-adding an inactive length reactivates it; an active duplicate is 409. */
create(months: number): Promise<WarrantyPeriod> {
return apiRequest<WarrantyPeriod>("/warranty-periods", { method: "POST", body: { months } })
},
/** Deactivate/reactivate — there is no DELETE (FR-MD-08). */
updateStatus(warrantyPeriodId: number, status: EntityStatus): Promise<void> {
return apiRequest<void>(`/warranty-periods/${warrantyPeriodId}/status`, { method: "PATCH", body: { status } })
},
}
@@ -84,7 +84,7 @@ export function validateTaxRate(code: string, name: string, percentage: number):
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
export function validateItemTypeName(name: string): Record<string, string> {
const errors: Record<string, string> = {}
if (!name.trim()) errors.name = "Item type name is required"
if (!name.trim()) errors.name = "Item variant name is required"
return errors
}
+51
View File
@@ -0,0 +1,51 @@
// Warranty period helpers. Periods are always stored in months; custom ones are the active
// rows of the Warranty Periods master and are only offered while
// ProductConfig.customWarrantyPeriodsEnabled is on.
import { ProductConfig, WARRANTY_PERIOD_MONTHS_OPTIONS } from "@/types/master-data"
/** Server limit (WarrantyPeriodService) — mirrored so forms fail fast. */
export const MAX_CUSTOM_WARRANTY_MONTHS = 600
/** "6 months", "1 month", or "24 months (2 years)" when it's a whole number of years. */
export function formatWarrantyPeriod(months: number): string {
const base = `${months} month${months === 1 ? "" : "s"}`
if (months >= 12 && months % 12 === 0) {
const years = months / 12
return `${base} (${years} year${years === 1 ? "" : "s"})`
}
return base
}
export type WarrantyInputUnit = "months" | "years"
/**
* Validates a typed length ("2" + years) against the existing active custom periods and
* converts it to months. Returns the months, or an error message fit to show under the input.
*/
export function parseWarrantyInput(
amount: string,
unit: WarrantyInputUnit,
existingCustom: number[],
): { months: number; error?: undefined } | { months?: undefined; error: string } {
const n = Number(amount)
if (!amount.trim() || !Number.isInteger(n) || n < 1) return { error: "Enter a whole number of 1 or more." }
const months = unit === "years" ? n * 12 : n
if (months > MAX_CUSTOM_WARRANTY_MONTHS)
return { error: `A warranty period can be at most ${MAX_CUSTOM_WARRANTY_MONTHS} months (50 years).` }
if ((WARRANTY_PERIOD_MONTHS_OPTIONS as readonly number[]).includes(months))
return { error: `${formatWarrantyPeriod(months)} is already a default period.` }
if (existingCustom.includes(months)) return { error: `${formatWarrantyPeriod(months)} already exists.` }
return { months }
}
/** The period choices for an item: the defaults, plus the active custom ones when enabled. */
export function warrantyPeriodGroups(
config: ProductConfig | null,
customMonths: number[],
): { defaults: number[]; custom: number[] } {
const defaults: number[] = [...WARRANTY_PERIOD_MONTHS_OPTIONS]
const custom = config?.customWarrantyPeriodsEnabled
? [...new Set(customMonths)].filter((m) => !defaults.includes(m)).sort((a, b) => a - b)
: []
return { defaults, custom }
}