product and sales fixers
This commit is contained in:
@@ -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 })
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user