From 62d362bd9e7d60f973c2aadba5fe1a3422bf291c Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Wed, 11 Feb 2026 17:42:19 +0530 Subject: [PATCH 1/4] [FEAT] Add quantity price breaks --- .../app/components/Grid/components/Row.tsx | 25 +- apps/erp/app/modules/items/items.models.ts | 5 +- .../items/ui/Item/SupplierPartForm.tsx | 91 +++- .../ui/Item/SupplierParts/SupplierParts.tsx | 43 +- .../sales/ui/Quotes/QuoteBillOfMaterial.tsx | 92 +++- .../sales/ui/Quotes/QuoteMaterialForm.tsx | 90 +++- ...itemId.view.purchasing.$supplierPartId.tsx | 108 +++++ ...itemId.view.purchasing.$supplierPartId.tsx | 108 +++++ ...itemId.view.purchasing.$supplierPartId.tsx | 108 +++++ .../x+/supplier-quote+/$id.finalize.tsx | 106 +++++ ...itemId.view.purchasing.$supplierPartId.tsx | 108 +++++ apps/erp/app/utils/path.ts | 10 +- packages/database/src/swagger-docs-schema.ts | 393 ++++++++++++++++++ packages/database/src/types.ts | 182 +++++++- .../supabase/functions/convert/index.ts | 109 +++++ .../database/supabase/functions/lib/types.ts | 182 +++++++- .../20260129150000_quantity-price-breaks.sql | 96 +++++ 17 files changed, 1805 insertions(+), 51 deletions(-) create mode 100644 apps/erp/app/routes/x+/consumable+/$itemId.view.purchasing.$supplierPartId.tsx create mode 100644 apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx create mode 100644 apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx create mode 100644 apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx create mode 100644 packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql diff --git a/apps/erp/app/components/Grid/components/Row.tsx b/apps/erp/app/components/Grid/components/Row.tsx index 4f917472e3..09ab80e34e 100644 --- a/apps/erp/app/components/Grid/components/Row.tsx +++ b/apps/erp/app/components/Grid/components/Row.tsx @@ -32,7 +32,8 @@ const Row = ({ rowRef, selectedCell, onCellClick, - onCellUpdate + onCellUpdate, + onEditRow }: RowProps) => { const onUpdate = onCellUpdate(row.index); @@ -40,7 +41,17 @@ const Row = ({ { + // Don't trigger row click if clicking a button/menu inside the row + const target = e.target as HTMLElement; + if (target.closest("button, a, [role='menuitem']")) return; + onEditRow(row.original); + } + : undefined + } > {row.getVisibleCells().map((cell, columnIndex) => { const isSelected = @@ -55,9 +66,13 @@ const Row = ({ // @ts-ignore editableComponents={editableComponents} editedCells={editedCells} - isSelected={isSelected} - isEditing={isEditing} - onClick={() => onCellClick(cell.row.index, columnIndex)} + isSelected={onEditRow ? false : isSelected} + isEditing={onEditRow ? false : isEditing} + onClick={ + onEditRow + ? undefined + : () => onCellClick(cell.row.index, columnIndex) + } onUpdate={onUpdate} /> ); diff --git a/apps/erp/app/modules/items/items.models.ts b/apps/erp/app/modules/items/items.models.ts index ec838e0a5a..5c756dd449 100644 --- a/apps/erp/app/modules/items/items.models.ts +++ b/apps/erp/app/modules/items/items.models.ts @@ -520,7 +520,10 @@ export const supplierPartValidator = z.object({ supplierUnitOfMeasureCode: zfd.text(z.string().optional()), minimumOrderQuantity: zfd.numeric(z.number().min(0)), conversionFactor: zfd.numeric(z.number().min(0)), - unitPrice: zfd.numeric(z.number().min(0)) + unitPrice: zfd.numeric(z.number().min(0).optional()), + lastPurchaseDate: z.string().optional(), + lastPOQuantity: zfd.numeric(z.number().min(0).optional()), + lastPOId: z.string().optional() }); export const toolValidator = itemValidator.merge( diff --git a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx index bd4b22eea7..7bbf35e5e4 100644 --- a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx +++ b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx @@ -1,3 +1,4 @@ +import { useCarbon } from "@carbon/auth"; import { ValidatedForm } from "@carbon/form"; import { Button, @@ -25,12 +26,15 @@ import { Supplier, UnitOfMeasure } from "~/components/Form"; -import { usePermissions, useUser } from "~/hooks"; +import { useCurrencyFormatter, usePermissions, useUser } from "~/hooks"; import { path } from "~/utils/path"; import { supplierPartValidator } from "../../items.models"; type SupplierPartFormProps = { - initialValues: z.infer; + initialValues: z.infer & { + lastPurchaseDate?: string | null; + lastPOQuantity?: number | null; + }; type: "Part" | "Service" | "Tool" | "Consumable" | "Material"; unitOfMeasureCode: string; onClose: () => void; @@ -42,7 +46,9 @@ const SupplierPartForm = ({ unitOfMeasureCode, onClose }: SupplierPartFormProps) => { + const { carbon } = useCarbon(); const permissions = usePermissions(); + const formatter = useCurrencyFormatter(); const { company } = useUser(); const baseCurrency = company?.baseCurrencyCode ?? "USD"; @@ -65,6 +71,29 @@ const SupplierPartForm = ({ const action = getAction(isEditing, type, itemId, initialValues.id); const fetcher = useFetcher<{ success: boolean; message: string }>(); + // Fetch price breaks for existing supplier parts + const [priceBreaks, setPriceBreaks] = useState< + { + quantity: number; + unitPrice: number; + leadTime: number | null; + sourceType: string; + }[] + >([]); + + useEffect(() => { + if (!carbon || !isEditing || !initialValues.id) return; + + carbon + .from("supplierPartPrice") + .select("quantity, unitPrice, leadTime, sourceType") + .eq("supplierPartId", initialValues.id) + .order("quantity", { ascending: true }) + .then(({ data }) => { + if (data?.length) setPriceBreaks(data); + }); + }, [carbon, isEditing, initialValues.id]); + useEffect(() => { if (fetcher.data?.success) { onClose(); @@ -110,6 +139,64 @@ const SupplierPartForm = ({ currency: baseCurrency }} /> + {/* Show last purchase info if available (read-only) */} + {initialValues.lastPurchaseDate && ( +
+
+ Last Purchase Info +
+
+ Date:{" "} + {new Date( + initialValues.lastPurchaseDate + ).toLocaleDateString()} +
+ {initialValues.lastPOQuantity != null && ( +
+ Quantity: {initialValues.lastPOQuantity.toLocaleString()} +
+ )} +
+ )} + {/* Show quantity price breaks if available */} + {priceBreaks.length > 0 && ( +
+
+ Price Breaks +
+ + + + + + + + + + + {priceBreaks.map((pb) => ( + + + + + + + ))} + +
QtyUnit PriceLead TimeSource
+ {pb.quantity.toLocaleString()} + + {formatter.format(pb.unitPrice)} + + {pb.leadTime != null ? `${pb.leadTime}d` : "—"} + + {pb.sourceType} +
+
+ )} ; type SupplierPartsProps = { @@ -37,10 +33,9 @@ const SupplierParts = ({ compact = false }: SupplierPartsProps) => { const navigate = useNavigate(); - const { canEdit, onCellEdit } = useSupplierParts(); + const { canEdit } = useSupplierParts(); const formatter = useCurrencyFormatter(); - const unitOfMeasureOptions = useUnitOfMeasure(); const customColumns = useCustomColumns("supplierPart"); const columns = useMemo[]>(() => { @@ -66,6 +61,24 @@ const SupplierParts = ({ renderTotal: true } }, + { + accessorKey: "lastPurchaseDate", + header: "Last Purchase", + cell: (item) => { + const date = item.getValue(); + if (!date) return "—"; + return new Date(date).toLocaleDateString(); + } + }, + { + accessorKey: "lastPOQuantity", + header: "Last PO Qty", + cell: (item) => { + const qty = item.getValue(); + if (qty == null) return "—"; + return qty.toLocaleString(); + } + }, { accessorKey: "supplierUnitOfMeasureCode", header: "Unit of Measure", @@ -85,17 +98,6 @@ const SupplierParts = ({ return [...defaultColumns, ...customColumns]; }, [customColumns, formatter]); - const editableComponents = useMemo( - () => ({ - supplierPartId: EditableText(onCellEdit), - supplierUnitOfMeasureCode: EditableList(onCellEdit, unitOfMeasureOptions), - minimumOrderQuantity: EditableNumber(onCellEdit), - conversionFactor: EditableNumber(onCellEdit), - unitPrice: EditableNumber(onCellEdit) - }), - [onCellEdit, unitOfMeasureOptions] - ); - return ( <> @@ -107,8 +109,7 @@ const SupplierParts = ({ contained={false} data={supplierParts} columns={columns} - canEdit={canEdit} - editableComponents={editableComponents} + onEditRow={(row) => navigate(row.id!)} onNewRow={canEdit ? () => navigate("new") : undefined} /> diff --git a/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx b/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx index 92798f18b3..4469c5d0b0 100644 --- a/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx +++ b/apps/erp/app/modules/sales/ui/Quotes/QuoteBillOfMaterial.tsx @@ -52,8 +52,6 @@ import { Hidden, InputControlled, Item, - // biome-ignore lint/suspicious/noShadowRestrictedNames: suppressed due to migration - Number, NumberControlled, Select, Shelf, @@ -688,6 +686,50 @@ function MaterialForm({ }); }; + // Lookup the best (lowest) price for a Buy item at a given quantity + // from supplierPartPrice across all vendors (SAP behavior) + const lookupBuyPrice = useCallback( + async (itemId: string, qty: number, fallbackCost: number) => { + if (!carbon) return fallbackCost; + + const supplierParts = await carbon + .from("supplierPart") + .select("id, unitPrice") + .eq("itemId", itemId); + + if (!supplierParts.data?.length) return fallbackCost; + + const supplierPartIds = supplierParts.data.map((sp) => sp.id); + + // Find the lowest price across all vendors for applicable qty tiers + // (SAP behavior: lowest valid PIR price wins) + const priceBreak = await carbon + .from("supplierPartPrice") + .select("unitPrice") + .in("supplierPartId", supplierPartIds) + .lte("quantity", qty) + .order("unitPrice", { ascending: true }) + .limit(1) + .single(); + + if (priceBreak.data?.unitPrice != null) { + return priceBreak.data.unitPrice; + } + + // Fall back to supplierPart.unitPrice (lowest across vendors) + const lowestSupplierPrice = supplierParts.data + .filter((sp) => sp.unitPrice != null) + .sort((a, b) => (a.unitPrice ?? 0) - (b.unitPrice ?? 0))[0]; + + if (lowestSupplierPrice?.unitPrice != null) { + return lowestSupplierPrice.unitPrice; + } + + return fallbackCost; + }, + [carbon] + ); + const onItemChange = async (itemId: string) => { if (!carbon) return; if (itemId === params.itemId) { @@ -712,11 +754,18 @@ function MaterialForm({ return; } + let unitCost = itemCost.data?.unitCost ?? 0; + const isBuyPart = item.data?.defaultMethodType === "Buy"; + + if (isBuyPart) { + unitCost = await lookupBuyPrice(itemId, itemData.quantity ?? 1, unitCost); + } + setItemData((d) => ({ ...d, itemId, description: item.data?.name ?? "", - unitCost: itemCost.data?.unitCost ?? 0, + unitCost, unitOfMeasureCode: item.data?.unitOfMeasureCode ?? "EA", methodType: item.data?.defaultMethodType ?? "Buy", requiresBatchTracking: item.data?.itemTrackingType === "Batch", @@ -728,6 +777,32 @@ function MaterialForm({ } }; + // Re-lookup price when quantity changes for Buy parts + const onQuantityChange = useCallback( + async (newQty: number) => { + setItemData((d) => ({ ...d, quantity: newQty })); + + if (itemData.methodType !== "Buy" || !itemData.itemId) return; + if (!carbon) return; + + const itemCost = await carbon + .from("itemCost") + .select("unitCost") + .eq("itemId", itemData.itemId) + .single(); + + const fallbackCost = itemCost.data?.unitCost ?? 0; + const unitCost = await lookupBuyPrice( + itemData.itemId, + newQty, + fallbackCost + ); + + setItemData((d) => ({ ...d, unitCost })); + }, + [carbon, itemData.methodType, itemData.itemId, lookupBuyPrice] + ); + const sourceDisclosure = useDisclosure(); const backflushDisclosure = useDisclosure(); const locationId = routeData?.quote?.locationId ?? undefined; @@ -756,7 +831,6 @@ function MaterialForm({ )} -
- + )}
-
-
- { + if (!carbon) return fallbackCost; + + const supplierParts = await carbon + .from("supplierPart") + .select("id, unitPrice") + .eq("itemId", itemId); + + if (!supplierParts.data?.length) return fallbackCost; + + const supplierPartIds = supplierParts.data.map((sp) => sp.id); + + // Find the lowest price across all vendors for applicable qty tiers + // (SAP behavior: lowest valid PIR price wins) + const priceBreak = await carbon + .from("supplierPartPrice") + .select("unitPrice") + .in("supplierPartId", supplierPartIds) + .lte("quantity", qty) + .order("unitPrice", { ascending: true }) + .limit(1) + .single(); + + if (priceBreak.data?.unitPrice != null) { + return priceBreak.data.unitPrice; + } + + // Fall back to supplierPart.unitPrice (lowest across vendors) + const lowestSupplierPrice = supplierParts.data + .filter((sp) => sp.unitPrice != null) + .sort((a, b) => (a.unitPrice ?? 0) - (b.unitPrice ?? 0))[0]; + + if (lowestSupplierPrice?.unitPrice != null) { + return lowestSupplierPrice.unitPrice; + } + + return fallbackCost; + }, + [carbon] + ); + const onItemChange = async (itemId: string) => { if (!carbon) return; @@ -107,16 +149,49 @@ const QuoteMaterialForm = ({ return; } + let unitCost = itemCost.data?.unitCost ?? 0; + const isBuyPart = item.data?.defaultMethodType === "Buy"; + + if (isBuyPart) { + unitCost = await lookupBuyPrice(itemId, itemData.quantity ?? 1, unitCost); + } + setItemData((d) => ({ ...d, itemId, description: item.data?.name ?? "", - unitCost: itemCost.data?.unitCost ?? 0, + unitCost, unitOfMeasureCode: item.data?.unitOfMeasureCode ?? "EA", methodType: item.data?.defaultMethodType ?? "Buy" })); }; + // Re-lookup price when quantity changes for Buy parts + const onQuantityChange = useCallback( + async (newQty: number) => { + setItemData((d) => ({ ...d, quantity: newQty })); + + if (itemData.methodType !== "Buy" || !itemData.itemId) return; + if (!carbon) return; + + const itemCost = await carbon + .from("itemCost") + .select("unitCost") + .eq("itemId", itemData.itemId) + .single(); + + const fallbackCost = itemCost.data?.unitCost ?? 0; + const unitCost = await lookupBuyPrice( + itemData.itemId, + newQty, + fallbackCost + ); + + setItemData((d) => ({ ...d, unitCost })); + }, + [carbon, itemData.methodType, itemData.itemId, lookupBuyPrice] + ); + const [, setSearchParams] = useUrlParams(); // biome-ignore lint/correctness/useExhaustiveDependencies: suppressed due to migration @@ -202,7 +277,12 @@ const QuoteMaterialForm = ({ value={itemData.methodType} replenishmentSystem="Buy and Make" /> - + (); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ consumableSummary: ConsumableSummary }>( + path.to.consumable(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.consumablePurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..4238b77415 --- /dev/null +++ b/apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx @@ -0,0 +1,108 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { MaterialSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts", + role: "employee" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts", + role: "employee" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return { success: false, message: "Invalid form data" }; + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.materialPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditMaterialSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ materialSummary: MaterialSummary }>( + path.to.material(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.materialPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..5e07b00eb5 --- /dev/null +++ b/apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx @@ -0,0 +1,108 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { PartSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts", + role: "employee" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts", + role: "employee" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return { success: false, message: "Invalid form data" }; + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.partPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditPartSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ partSummary: PartSummary }>( + path.to.part(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.partPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx index ae942d5874..187f0cce7e 100644 --- a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx +++ b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx @@ -126,6 +126,112 @@ export async function action(args: ActionFunctionArgs) { ) ); } + + // Copy price breaks from supplierQuoteLinePrice → supplierPartPrice + // This makes prices available for customer quote costing immediately + const supplierId = quote.data.supplierId; + if (!supplierId) throw new Error("Supplier quote has no supplier"); + + for (const line of lines) { + if (!line.id || !line.itemId) continue; + + const linePrices = prices.filter( + (p) => p.supplierQuoteLineId === line.id + ); + if (linePrices.length === 0) continue; + + // Find or create the supplierPart record for this item+supplier + const existingPart = await client + .from("supplierPart") + .select("id") + .eq("itemId", line.itemId) + .eq("supplierId", supplierId) + .eq("companyId", companyId) + .single(); + + let supplierPartId: string | undefined; + + if (existingPart.data?.id) { + supplierPartId = existingPart.data.id; + } else { + // Create a new supplierPart record + const newPart = await client + .from("supplierPart") + .insert({ + itemId: line.itemId, + supplierId, + supplierPartId: line.supplierPartId ?? undefined, + supplierUnitOfMeasureCode: + line.purchaseUnitOfMeasureCode ?? undefined, + conversionFactor: line.conversionFactor ?? 1, + companyId, + createdBy: userId + }) + .select("id") + .single(); + + if (newPart.error || !newPart.data?.id) { + console.error("Error creating supplier part:", newPart.error); + continue; + } + supplierPartId = newPart.data.id; + } + + if (!supplierPartId) continue; + + // Upsert price breaks into supplierPartPrice + const conversionFactor = line.conversionFactor ?? 1; + + for (const price of linePrices) { + if (!price.supplierUnitPrice || price.supplierUnitPrice === 0) continue; + + // Use the pre-computed unitPrice (= supplierUnitPrice / exchangeRate) + // which is already in company currency but still in purchase units. + // Divide by conversionFactor to get inventory unit price. + const unitPriceInInventoryUnit = + (price.unitPrice ?? 0) / conversionFactor; + + const upsertResult = await client.from("supplierPartPrice").upsert( + { + supplierPartId, + quantity: price.quantity ?? 1, + unitPrice: unitPriceInInventoryUnit, + leadTime: price.leadTime ?? 0, + sourceType: "Quote", + sourceDocumentId: id, + companyId, + createdBy: userId, + updatedBy: userId, + updatedAt: new Date().toISOString() + }, + { onConflict: "supplierPartId,quantity" } + ); + + if (upsertResult.error) { + console.error( + "Error upserting supplier part price:", + upsertResult.error + ); + } + } + + // Update supplierPart.unitPrice with the best (lowest) unit price + // across all tiers — gives purchasing a quick "best available" reference + const bestPrice = linePrices + .filter((p) => p.unitPrice != null && p.unitPrice !== 0) + .sort( + (a, b) => (a.unitPrice ?? Infinity) - (b.unitPrice ?? Infinity) + )[0]; + + if (bestPrice) { + await client + .from("supplierPart") + .update({ + unitPrice: (bestPrice.unitPrice ?? 0) / conversionFactor + }) + .eq("id", supplierPartId); + } + } } catch (err) { throw redirect( path.to.supplierQuote(id), diff --git a/apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx new file mode 100644 index 0000000000..3bbfbde320 --- /dev/null +++ b/apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx @@ -0,0 +1,108 @@ +import { assertIsPost, success } from "@carbon/auth"; +import { requirePermissions } from "@carbon/auth/auth.server"; +import { flash } from "@carbon/auth/session.server"; +import { validator } from "@carbon/form"; +import { useRouteData } from "@carbon/remix"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; +import type { ToolSummary } from "~/modules/items"; +import { supplierPartValidator, upsertSupplierPart } from "~/modules/items"; +import { SupplierPartForm } from "~/modules/items/ui/Item"; +import { setCustomFields } from "~/utils/form"; +import { path } from "~/utils/path"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const { client, companyId } = await requirePermissions(request, { + view: "parts", + role: "employee" + }); + + const { supplierPartId } = params; + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const supplierPart = await client + .from("supplierPart") + .select("*") + .eq("id", supplierPartId) + .eq("companyId", companyId) + .single(); + + if (!supplierPart?.data) throw new Error("Could not find supplier part"); + + return { supplierPart: supplierPart.data }; +} + +export async function action({ request, params }: ActionFunctionArgs) { + assertIsPost(request); + const { client, userId } = await requirePermissions(request, { + update: "parts", + role: "employee" + }); + + const { itemId, supplierPartId } = params; + if (!itemId) throw new Error("Could not find itemId"); + if (!supplierPartId) throw new Error("Could not find supplierPartId"); + + const formData = await request.formData(); + const validation = await validator(supplierPartValidator).validate(formData); + + if (validation.error) { + return { success: false, message: "Invalid form data" }; + } + + // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration + const { id, ...d } = validation.data; + + const updatedSupplierPart = await upsertSupplierPart(client, { + id: supplierPartId, + ...d, + updatedBy: userId, + customFields: setCustomFields(formData) + }); + + if (updatedSupplierPart.error) { + return { success: false, message: "Failed to update supplier part" }; + } + + throw redirect( + path.to.toolPurchasing(itemId), + await flash(request, success("Supplier part updated")) + ); +} + +export default function EditToolSupplierRoute() { + const { itemId } = useParams(); + const { supplierPart } = useLoaderData(); + + if (!itemId) throw new Error("itemId not found"); + + const routeData = useRouteData<{ toolSummary: ToolSummary }>( + path.to.tool(itemId) + ); + + const navigate = useNavigate(); + const onClose = () => navigate(path.to.toolPurchasing(itemId)); + + const initialValues = { + id: supplierPart.id, + itemId: supplierPart.itemId, + supplierId: supplierPart.supplierId, + supplierPartId: supplierPart.supplierPartId ?? "", + unitPrice: supplierPart.unitPrice ?? 0, + supplierUnitOfMeasureCode: supplierPart.supplierUnitOfMeasureCode ?? "EA", + minimumOrderQuantity: supplierPart.minimumOrderQuantity ?? 1, + conversionFactor: supplierPart.conversionFactor ?? 1, + lastPurchaseDate: supplierPart.lastPurchaseDate, + lastPOQuantity: supplierPart.lastPOQuantity, + lastPOId: supplierPart.lastPOId + }; + + return ( + + ); +} diff --git a/apps/erp/app/utils/path.ts b/apps/erp/app/utils/path.ts index 84859f5197..da35fb3c6f 100644 --- a/apps/erp/app/utils/path.ts +++ b/apps/erp/app/utils/path.ts @@ -480,7 +480,7 @@ export const path = { generatePath(`${x}/consumable/${id}/view/purchasing`), consumableRoot: `${x}/consumable`, consumableSupplier: (itemId: string, id: string) => - generatePath(`${x}/consumable/${itemId}/view/suppliers/${id}`), + generatePath(`${x}/consumable/${itemId}/view/purchasing/${id}`), consumableSuppliers: (id: string) => generatePath(`${x}/consumable/${id}/view/suppliers`), convertQuoteToOrder: (id: string) => @@ -967,7 +967,7 @@ export const path = { generatePath(`${x}/material/${id}/view/purchasing`), materialRoot: `${x}/material`, materialSupplier: (itemId: string, id: string) => - generatePath(`${x}/material/${itemId}/view/suppliers/${id}`), + generatePath(`${x}/material/${itemId}/view/purchasing/${id}`), materialSuppliers: (id: string) => generatePath(`${x}/material/${id}/view/suppliers`), materials: `${x}/items/materials`, @@ -1194,7 +1194,7 @@ export const path = { partRoot: `${x}/part`, partSales: (id: string) => generatePath(`${x}/part/${id}/view/sales`), partSupplier: (itemId: string, id: string) => - generatePath(`${x}/part/${itemId}/suppliers/${id}`), + generatePath(`${x}/part/${itemId}/view/purchasing/${id}`), parts: `${x}/items/parts`, partner: (id: string, abilityId: string) => generatePath(`${x}/resources/partners/${id}/${abilityId}`), @@ -1481,7 +1481,7 @@ export const path = { servicePurchasing: (id: string) => generatePath(`${x}/service/${id}/purchasing`), serviceSupplier: (serviceId: string, id: string) => - generatePath(`${x}/service/${serviceId}/suppliers/${id}`), + generatePath(`${x}/service/${serviceId}/purchasing/${id}`), serviceSuppliers: (id: string) => generatePath(`${x}/service/${id}/suppliers`), settings: `${x}/settings`, @@ -1599,7 +1599,7 @@ export const path = { generatePath(`${x}/tool/${id}/view/purchasing`), toolRoot: `${x}/tool`, toolSupplier: (itemId: string, id: string) => - generatePath(`${x}/tool/${itemId}/view/suppliers/${id}`), + generatePath(`${x}/tool/${itemId}/view/purchasing/${id}`), toolSuppliers: (id: string) => generatePath(`${x}/tool/${id}/view/suppliers`), tools: `${x}/items/tools`, diff --git a/packages/database/src/swagger-docs-schema.ts b/packages/database/src/swagger-docs-schema.ts index 0447021b3d..83139a7acb 100644 --- a/packages/database/src/swagger-docs-schema.ts +++ b/packages/database/src/swagger-docs-schema.ts @@ -10701,6 +10701,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/select", }, @@ -10808,6 +10817,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/preferReturn", }, @@ -10869,6 +10887,15 @@ export default { { $ref: "#/parameters/rowFilter.supplierPart.tags", }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPurchaseDate", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOQuantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPart.lastPOId", + }, { $ref: "#/parameters/body.supplierPart", }, @@ -22581,6 +22608,195 @@ export default { tags: ["job"], }, }, + "/supplierPartPrice": { + get: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/select", + }, + { + $ref: "#/parameters/order", + }, + { + $ref: "#/parameters/range", + }, + { + $ref: "#/parameters/rangeUnit", + }, + { + $ref: "#/parameters/offset", + }, + { + $ref: "#/parameters/limit", + }, + { + $ref: "#/parameters/preferCount", + }, + ], + responses: { + "200": { + description: "OK", + schema: { + items: { + $ref: "#/definitions/supplierPartPrice", + }, + type: "array", + }, + }, + "206": { + description: "Partial Content", + }, + }, + tags: ["supplierPartPrice"], + }, + post: { + parameters: [ + { + $ref: "#/parameters/body.supplierPartPrice", + }, + { + $ref: "#/parameters/select", + }, + { + $ref: "#/parameters/preferPost", + }, + ], + responses: { + "201": { + description: "Created", + }, + }, + tags: ["supplierPartPrice"], + }, + delete: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/preferReturn", + }, + ], + responses: { + "204": { + description: "No Content", + }, + }, + tags: ["supplierPartPrice"], + }, + patch: { + parameters: [ + { + $ref: "#/parameters/rowFilter.supplierPartPrice.supplierPartId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.quantity", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.unitPrice", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.leadTime", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceType", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.sourceDocumentId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.companyId", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.createdAt", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedBy", + }, + { + $ref: "#/parameters/rowFilter.supplierPartPrice.updatedAt", + }, + { + $ref: "#/parameters/body.supplierPartPrice", + }, + { + $ref: "#/parameters/preferReturn", + }, + ], + responses: { + "204": { + description: "No Content", + }, + }, + tags: ["supplierPartPrice"], + }, + }, "/contact": { get: { parameters: [ @@ -71896,6 +72112,20 @@ export default { }, type: "array", }, + lastPurchaseDate: { + format: "timestamp with time zone", + type: "string", + }, + lastPOQuantity: { + format: "numeric", + type: "number", + }, + lastPOId: { + description: + "Note:\nThis is a Foreign Key to `purchaseOrder.id`.", + format: "text", + type: "string", + }, }, type: "object", }, @@ -77295,6 +77525,76 @@ export default { }, type: "object", }, + supplierPartPrice: { + required: [ + "supplierPartId", + "quantity", + "unitPrice", + "sourceType", + "companyId", + "createdBy", + "createdAt", + ], + properties: { + supplierPartId: { + description: "Note:\nThis is a Primary Key.", + format: "text", + type: "string", + }, + quantity: { + default: 1, + description: "Note:\nThis is a Primary Key.", + format: "numeric", + type: "number", + }, + unitPrice: { + format: "numeric", + type: "number", + }, + leadTime: { + default: 0, + format: "numeric", + type: "number", + }, + sourceType: { + default: "Quote", + format: "text", + type: "string", + }, + sourceDocumentId: { + format: "text", + type: "string", + }, + companyId: { + description: + "Note:\nThis is a Foreign Key to `company.id`.", + format: "text", + type: "string", + }, + createdBy: { + description: + "Note:\nThis is a Foreign Key to `user.id`.", + format: "text", + type: "string", + }, + createdAt: { + default: "now()", + format: "timestamp with time zone", + type: "string", + }, + updatedBy: { + description: + "Note:\nThis is a Foreign Key to `user.id`.", + format: "text", + type: "string", + }, + updatedAt: { + format: "timestamp with time zone", + type: "string", + }, + }, + type: "object", + }, contact: { required: ["id", "companyId", "isCustomer"], properties: { @@ -102356,6 +102656,24 @@ export default { in: "query", type: "string", }, + "rowFilter.supplierPart.lastPurchaseDate": { + name: "lastPurchaseDate", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPart.lastPOQuantity": { + name: "lastPOQuantity", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPart.lastPOId": { + name: "lastPOId", + required: false, + in: "query", + type: "string", + }, "body.partner": { name: "partner", description: "partner", @@ -108373,6 +108691,81 @@ export default { in: "query", type: "string", }, + "body.supplierPartPrice": { + name: "supplierPartPrice", + description: "supplierPartPrice", + required: false, + in: "body", + schema: { + $ref: "#/definitions/supplierPartPrice", + }, + }, + "rowFilter.supplierPartPrice.supplierPartId": { + name: "supplierPartId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.quantity": { + name: "quantity", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.unitPrice": { + name: "unitPrice", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.leadTime": { + name: "leadTime", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.sourceType": { + name: "sourceType", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.sourceDocumentId": { + name: "sourceDocumentId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.companyId": { + name: "companyId", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.createdBy": { + name: "createdBy", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.createdAt": { + name: "createdAt", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.updatedBy": { + name: "updatedBy", + required: false, + in: "query", + type: "string", + }, + "rowFilter.supplierPartPrice.updatedAt": { + name: "updatedAt", + required: false, + in: "query", + type: "string", + }, "body.contact": { name: "contact", description: "contact", diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index ba7533ce01..38b4a6785f 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -35651,6 +35651,9 @@ export type Database = { customFields: Json | null id: string itemId: string + lastPOId: string | null + lastPOQuantity: number | null + lastPurchaseDate: string | null minimumOrderQuantity: number | null supplierId: string supplierPartId: string | null @@ -35669,6 +35672,9 @@ export type Database = { customFields?: Json | null id?: string itemId: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId: string supplierPartId?: string | null @@ -35687,6 +35693,9 @@ export type Database = { customFields?: Json | null id?: string itemId?: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId?: string supplierPartId?: string | null @@ -35865,6 +35874,175 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrder" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrderLocations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrders" + referencedColumns: ["id"] + }, + ] + } + supplierPartPrice: { + Row: { + companyId: string + createdAt: string + createdBy: string + leadTime: number | null + quantity: number + sourceDocumentId: string | null + sourceType: string + supplierPartId: string + unitPrice: number + updatedAt: string | null + updatedBy: string | null + } + Insert: { + companyId: string + createdAt?: string + createdBy: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId: string + unitPrice: number + updatedAt?: string | null + updatedBy?: string | null + } + Update: { + companyId?: string + createdAt?: string + createdBy?: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId?: string + unitPrice?: number + updatedAt?: string | null + updatedBy?: string | null + } + Relationships: [ + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "companies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "company" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "customFieldTables" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "integrations" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, + { + foreignKeyName: "supplierPartPrice_supplierPartId_companyId_fkey" + columns: ["supplierPartId", "companyId"] + isOneToOne: false + referencedRelation: "supplierPart" + referencedColumns: ["id", "companyId"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, ] } supplierPayment: { @@ -51274,14 +51452,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] diff --git a/packages/database/supabase/functions/convert/index.ts b/packages/database/supabase/functions/convert/index.ts index 67a1cf17a0..0220e2dabe 100644 --- a/packages/database/supabase/functions/convert/index.ts +++ b/packages/database/supabase/functions/convert/index.ts @@ -1502,6 +1502,20 @@ serve(async (req: Request) => { ) .forEach((line) => { const key = `${line.itemId}-${quote.data.supplierId}`; + const selectedLine = selectedLines![line.id!]; + // Calculate unit price in inventory unit (company currency) + // exchangeRate = supplier currency per 1 company currency (e.g., 130 JPY/USD) + // So: company price = supplierUnitPrice / exchangeRate + // Then divide by conversionFactor to get inventory unit price + const exchangeRate = quote.data.exchangeRate ?? 1; + const unitPriceInInventoryUnit = + (selectedLine.supplierUnitPrice / + (exchangeRate === 0 ? 1 : exchangeRate)) / + (line.conversionFactor ?? 1); + // Calculate quantity in inventory units + const quantityInInventoryUnits = + selectedLine.quantity * (line.conversionFactor ?? 1); + supplierPartMap.set(key, { companyId, supplierId: quote.data?.supplierId!, @@ -1510,6 +1524,11 @@ serve(async (req: Request) => { conversionFactor: line.conversionFactor, itemId: line.itemId!, createdBy: userId, + // New pricing fields + unitPrice: unitPriceInInventoryUnit, + lastPurchaseDate: new Date().toISOString(), + lastPOQuantity: quantityInInventoryUnits, + lastPOId: insertedPurchaseOrderId, }); }); @@ -1526,9 +1545,99 @@ serve(async (req: Request) => { .columns(["itemId", "supplierId", "companyId"]) .doUpdateSet((eb) => ({ supplierPartId: eb.ref("excluded.supplierPartId"), + // Update pricing fields on conflict + unitPrice: eb.ref("excluded.unitPrice"), + lastPurchaseDate: eb.ref("excluded.lastPurchaseDate"), + lastPOQuantity: eb.ref("excluded.lastPOQuantity"), + lastPOId: eb.ref("excluded.lastPOId"), })) ) .execute(); + + // Also upsert into supplierPartPrice with sourceType='PurchaseOrder' + // This confirms the price tier for the specific quantity ordered + const supplierParts = await trx + .selectFrom("supplierPart") + .select(["id", "itemId"]) + .where("supplierId", "=", quote.data.supplierId) + .where("companyId", "=", companyId) + .where( + "itemId", + "in", + supplierPartToItemInserts.map((i: { itemId: string }) => i.itemId) + ) + .execute(); + + const supplierPartIdByItemId = new Map( + supplierParts.map((sp) => [sp.itemId, sp.id]) + ); + + for (const line of quoteLines.data.filter( + (l) => + !!l.itemId && + l.id && + selectedLines && + l.id in selectedLines + )) { + const spId = supplierPartIdByItemId.get(line.itemId); + if (!spId) continue; + + const selectedLine = selectedLines![line.id!]; + const exchangeRate = quote.data.exchangeRate ?? 1; + const conversionFactor = line.conversionFactor ?? 1; + // exchangeRate = supplier currency per 1 company currency + // Divide to convert to company currency, then divide by conversionFactor + const unitPriceInInventoryUnit = + (selectedLine.supplierUnitPrice / + (exchangeRate === 0 ? 1 : exchangeRate)) / + conversionFactor; + + await trx + .insertInto("supplierPartPrice") + .values({ + supplierPartId: spId, + quantity: selectedLine.quantity, + unitPrice: unitPriceInInventoryUnit, + leadTime: selectedLine.leadTime ?? 0, + sourceType: "PurchaseOrder", + sourceDocumentId: insertedPurchaseOrderId, + companyId, + createdBy: userId, + updatedBy: userId, + updatedAt: new Date().toISOString(), + }) + .onConflict((oc) => + oc + .columns(["supplierPartId", "quantity"]) + .doUpdateSet((eb) => ({ + unitPrice: eb.ref("excluded.unitPrice"), + leadTime: eb.ref("excluded.leadTime"), + sourceType: eb.ref("excluded.sourceType"), + sourceDocumentId: eb.ref("excluded.sourceDocumentId"), + updatedBy: eb.ref("excluded.updatedBy"), + updatedAt: eb.ref("excluded.updatedAt"), + })) + ) + .execute(); + } + + // Update each supplierPart.unitPrice with the best (lowest) price + // across all tiers (quotes + POs + manual) + for (const [, spId] of supplierPartIdByItemId) { + const result = await trx + .selectFrom("supplierPartPrice") + .select((eb) => eb.fn.min("unitPrice").as("minPrice")) + .where("supplierPartId", "=", spId) + .executeTakeFirst(); + + if (result?.minPrice != null) { + await trx + .updateTable("supplierPart") + .set({ unitPrice: Number(result.minPrice) }) + .where("id", "=", spId) + .execute(); + } + } } }); diff --git a/packages/database/supabase/functions/lib/types.ts b/packages/database/supabase/functions/lib/types.ts index ba7533ce01..38b4a6785f 100644 --- a/packages/database/supabase/functions/lib/types.ts +++ b/packages/database/supabase/functions/lib/types.ts @@ -35651,6 +35651,9 @@ export type Database = { customFields: Json | null id: string itemId: string + lastPOId: string | null + lastPOQuantity: number | null + lastPurchaseDate: string | null minimumOrderQuantity: number | null supplierId: string supplierPartId: string | null @@ -35669,6 +35672,9 @@ export type Database = { customFields?: Json | null id?: string itemId: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId: string supplierPartId?: string | null @@ -35687,6 +35693,9 @@ export type Database = { customFields?: Json | null id?: string itemId?: string + lastPOId?: string | null + lastPOQuantity?: number | null + lastPurchaseDate?: string | null minimumOrderQuantity?: number | null supplierId?: string supplierPartId?: string | null @@ -35865,6 +35874,175 @@ export type Database = { referencedRelation: "userDefaults" referencedColumns: ["userId"] }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrder" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrderLocations" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPart_lastPOId_fkey" + columns: ["lastPOId"] + isOneToOne: false + referencedRelation: "purchaseOrders" + referencedColumns: ["id"] + }, + ] + } + supplierPartPrice: { + Row: { + companyId: string + createdAt: string + createdBy: string + leadTime: number | null + quantity: number + sourceDocumentId: string | null + sourceType: string + supplierPartId: string + unitPrice: number + updatedAt: string | null + updatedBy: string | null + } + Insert: { + companyId: string + createdAt?: string + createdBy: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId: string + unitPrice: number + updatedAt?: string | null + updatedBy?: string | null + } + Update: { + companyId?: string + createdAt?: string + createdBy?: string + leadTime?: number | null + quantity?: number + sourceDocumentId?: string | null + sourceType?: string + supplierPartId?: string + unitPrice?: number + updatedAt?: string | null + updatedBy?: string | null + } + Relationships: [ + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "companies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "company" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "customFieldTables" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_companyId_fkey" + columns: ["companyId"] + isOneToOne: false + referencedRelation: "integrations" + referencedColumns: ["companyId"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_createdBy_fkey" + columns: ["createdBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, + { + foreignKeyName: "supplierPartPrice_supplierPartId_companyId_fkey" + columns: ["supplierPartId", "companyId"] + isOneToOne: false + referencedRelation: "supplierPart" + referencedColumns: ["id", "companyId"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employees" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeesAcrossCompanies" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "employeeSummary" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "supplierPartPrice_updatedBy_fkey" + columns: ["updatedBy"] + isOneToOne: false + referencedRelation: "userDefaults" + referencedColumns: ["userId"] + }, ] } supplierPayment: { @@ -51274,14 +51452,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] diff --git a/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql b/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql new file mode 100644 index 0000000000..9d2be8ed3b --- /dev/null +++ b/packages/database/supabase/migrations/20260129150000_quantity-price-breaks.sql @@ -0,0 +1,96 @@ +-- Add pricing fields to supplierPart table and create supplierPartPrice child table +-- This enables quantity-based pricing for customer quote material costing + +-- ============================================================================ +-- Part 1: Add "last price" fields to supplierPart (backward compat / quick ref) +-- ============================================================================ + +ALTER TABLE "supplierPart" +ADD COLUMN IF NOT EXISTS "unitPrice" NUMERIC(15, 5), +ADD COLUMN IF NOT EXISTS "lastPurchaseDate" TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS "lastPOQuantity" NUMERIC(20, 2), +ADD COLUMN IF NOT EXISTS "lastPOId" TEXT; + +ALTER TABLE "supplierPart" +ADD CONSTRAINT "supplierPart_lastPOId_fkey" +FOREIGN KEY ("lastPOId") REFERENCES "purchaseOrder"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +-- ============================================================================ +-- Part 2: Create supplierPartPrice child table (quantity scales) +-- ============================================================================ + +CREATE TABLE "supplierPartPrice" ( + "supplierPartId" TEXT NOT NULL, + "quantity" NUMERIC(20, 2) NOT NULL DEFAULT 1, + "unitPrice" NUMERIC(15, 5) NOT NULL, + "leadTime" NUMERIC(10, 5) DEFAULT 0, + "sourceType" TEXT NOT NULL DEFAULT 'Quote', + "sourceDocumentId" TEXT, + "companyId" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + "updatedBy" TEXT, + "updatedAt" TIMESTAMP WITH TIME ZONE, + + CONSTRAINT "supplierPartPrice_pkey" PRIMARY KEY ("supplierPartId", "quantity"), + CONSTRAINT "supplierPartPrice_supplierPartId_companyId_fkey" + FOREIGN KEY ("supplierPartId", "companyId") REFERENCES "supplierPart"("id", "companyId") + ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "supplierPartPrice_companyId_fkey" + FOREIGN KEY ("companyId") REFERENCES "company"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "supplierPartPrice_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "user"("id"), + CONSTRAINT "supplierPartPrice_updatedBy_fkey" + FOREIGN KEY ("updatedBy") REFERENCES "user"("id"), + CONSTRAINT "supplierPartPrice_sourceType_check" + CHECK ("sourceType" IN ('Quote', 'PurchaseOrder', 'Manual')) +); + +-- RLS: same access patterns as supplierPart +ALTER TABLE "supplierPartPrice" ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Employees with part/purchasing_view can view supplier part prices" ON "supplierPartPrice" + FOR SELECT + USING ( + ( + has_company_permission('parts_view', "companyId") OR + has_company_permission('purchasing_view', "companyId") + ) + AND has_role('employee', "companyId") + ); + +CREATE POLICY "Employees with parts_create can create supplier part prices" ON "supplierPartPrice" + FOR INSERT + WITH CHECK ( + has_role('employee', "companyId") AND + has_company_permission('parts_create', "companyId") + ); + +CREATE POLICY "Employees with parts_update can update supplier part prices" ON "supplierPartPrice" + FOR UPDATE + USING ( + has_role('employee', "companyId") AND + has_company_permission('parts_update', "companyId") + ); + +CREATE POLICY "Employees with parts_delete can delete supplier part prices" ON "supplierPartPrice" + FOR DELETE + USING ( + has_role('employee', "companyId") AND + has_company_permission('parts_delete', "companyId") + ); + +-- Suppliers can view their own supplier part prices +CREATE POLICY "Suppliers can view their own supplier part prices" ON "supplierPartPrice" + FOR SELECT + USING ( + has_role('supplier', "companyId") AND + has_company_permission('parts_view', "companyId") AND + "supplierPartId" IN ( + SELECT "id" FROM "supplierPart" WHERE "supplierId" IN ( + SELECT "supplierId" FROM "supplierAccount" WHERE id::uuid = auth.uid() + ) + ) + ); From 3dd37fe5580fe378dfe7a9fd2b480108aec4a50b Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Wed, 11 Feb 2026 17:51:20 +0530 Subject: [PATCH 2/4] UI changes --- .../modules/items/ui/Item/SupplierPartForm.tsx | 4 ++-- .../routes/x+/supplier-quote+/$id.finalize.tsx | 3 ++- .../database/supabase/functions/convert/index.ts | 16 ++++++++++------ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx index 7bbf35e5e4..78c316a2da 100644 --- a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx +++ b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx @@ -109,7 +109,7 @@ const SupplierPartForm = ({ if (!open) onClose(); }} > - + 0 && ( -
+
Price Breaks
diff --git a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx index 187f0cce7e..737200c90b 100644 --- a/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx +++ b/apps/erp/app/routes/x+/supplier-quote+/$id.finalize.tsx @@ -227,7 +227,8 @@ export async function action(args: ActionFunctionArgs) { await client .from("supplierPart") .update({ - unitPrice: (bestPrice.unitPrice ?? 0) / conversionFactor + unitPrice: (bestPrice.unitPrice ?? 0) / conversionFactor, + minimumOrderQuantity: bestPrice.quantity ?? 1 }) .eq("id", supplierPartId); } diff --git a/packages/database/supabase/functions/convert/index.ts b/packages/database/supabase/functions/convert/index.ts index 0220e2dabe..97d122b9d0 100644 --- a/packages/database/supabase/functions/convert/index.ts +++ b/packages/database/supabase/functions/convert/index.ts @@ -1621,19 +1621,23 @@ serve(async (req: Request) => { .execute(); } - // Update each supplierPart.unitPrice with the best (lowest) price - // across all tiers (quotes + POs + manual) + // Update each supplierPart with the best (lowest) price and its + // corresponding MOQ across all tiers (quotes + POs + manual) for (const [, spId] of supplierPartIdByItemId) { - const result = await trx + const bestTier = await trx .selectFrom("supplierPartPrice") - .select((eb) => eb.fn.min("unitPrice").as("minPrice")) + .select(["unitPrice", "quantity"]) .where("supplierPartId", "=", spId) + .orderBy("unitPrice", "asc") .executeTakeFirst(); - if (result?.minPrice != null) { + if (bestTier) { await trx .updateTable("supplierPart") - .set({ unitPrice: Number(result.minPrice) }) + .set({ + unitPrice: Number(bestTier.unitPrice), + minimumOrderQuantity: Number(bestTier.quantity), + }) .where("id", "=", spId) .execute(); } From 80228736f9e7b0804e27a0e100a976098a674778 Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Mon, 16 Feb 2026 23:50:47 +0530 Subject: [PATCH 3/4] - fixes --- ...$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} | 0 ...$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} | 0 ...$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} | 0 ...$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename apps/erp/app/routes/x+/consumable+/{$itemId.view.purchasing.$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} (100%) rename apps/erp/app/routes/x+/material+/{$itemId.view.purchasing.$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} (100%) rename apps/erp/app/routes/x+/part+/{$itemId.view.purchasing.$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} (100%) rename apps/erp/app/routes/x+/tool+/{$itemId.view.purchasing.$supplierPartId.tsx => $itemId.purchasing.$supplierPartId.tsx} (100%) diff --git a/apps/erp/app/routes/x+/consumable+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx similarity index 100% rename from apps/erp/app/routes/x+/consumable+/$itemId.view.purchasing.$supplierPartId.tsx rename to apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx diff --git a/apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx similarity index 100% rename from apps/erp/app/routes/x+/material+/$itemId.view.purchasing.$supplierPartId.tsx rename to apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx diff --git a/apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx similarity index 100% rename from apps/erp/app/routes/x+/part+/$itemId.view.purchasing.$supplierPartId.tsx rename to apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx diff --git a/apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx similarity index 100% rename from apps/erp/app/routes/x+/tool+/$itemId.view.purchasing.$supplierPartId.tsx rename to apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx From 83d2c3d474e7f668ee1da0831b21ca9316e6f157 Mon Sep 17 00:00:00 2001 From: Gaurav Bhatt Date: Mon, 16 Feb 2026 23:51:00 +0530 Subject: [PATCH 4/4] - updated to using tables --- .../app/components/Grid/components/Row.tsx | 25 +++-------- .../items/ui/Item/SupplierPartForm.tsx | 5 +-- .../ui/Item/SupplierParts/SupplierParts.tsx | 43 +++++++++++-------- .../SupplierQuoteCompareDrawer.tsx | 5 ++- .../$itemId.purchasing.$supplierPartId.tsx | 10 ++--- .../$itemId.purchasing.$supplierPartId.tsx | 10 ++--- .../$itemId.purchasing.$supplierPartId.tsx | 6 +-- .../$itemId.purchasing.$supplierPartId.tsx | 6 +-- apps/erp/app/utils/path.ts | 8 ++-- packages/database/src/types.ts | 4 +- .../database/supabase/functions/lib/types.ts | 4 +- 11 files changed, 55 insertions(+), 71 deletions(-) diff --git a/apps/erp/app/components/Grid/components/Row.tsx b/apps/erp/app/components/Grid/components/Row.tsx index 09ab80e34e..4f917472e3 100644 --- a/apps/erp/app/components/Grid/components/Row.tsx +++ b/apps/erp/app/components/Grid/components/Row.tsx @@ -32,8 +32,7 @@ const Row = ({ rowRef, selectedCell, onCellClick, - onCellUpdate, - onEditRow + onCellUpdate }: RowProps) => { const onUpdate = onCellUpdate(row.index); @@ -41,17 +40,7 @@ const Row = ({ { - // Don't trigger row click if clicking a button/menu inside the row - const target = e.target as HTMLElement; - if (target.closest("button, a, [role='menuitem']")) return; - onEditRow(row.original); - } - : undefined - } + className={cn(rowIsClickable && "cursor-pointer")} > {row.getVisibleCells().map((cell, columnIndex) => { const isSelected = @@ -66,13 +55,9 @@ const Row = ({ // @ts-ignore editableComponents={editableComponents} editedCells={editedCells} - isSelected={onEditRow ? false : isSelected} - isEditing={onEditRow ? false : isEditing} - onClick={ - onEditRow - ? undefined - : () => onCellClick(cell.row.index, columnIndex) - } + isSelected={isSelected} + isEditing={isEditing} + onClick={() => onCellClick(cell.row.index, columnIndex)} onUpdate={onUpdate} /> ); diff --git a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx index 78c316a2da..f82c018c18 100644 --- a/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx +++ b/apps/erp/app/modules/items/ui/Item/SupplierPartForm.tsx @@ -31,10 +31,7 @@ import { path } from "~/utils/path"; import { supplierPartValidator } from "../../items.models"; type SupplierPartFormProps = { - initialValues: z.infer & { - lastPurchaseDate?: string | null; - lastPOQuantity?: number | null; - }; + initialValues: z.infer; type: "Part" | "Service" | "Tool" | "Consumable" | "Material"; unitOfMeasureCode: string; onClose: () => void; diff --git a/apps/erp/app/modules/items/ui/Item/SupplierParts/SupplierParts.tsx b/apps/erp/app/modules/items/ui/Item/SupplierParts/SupplierParts.tsx index c84cac840a..2891ca6f12 100644 --- a/apps/erp/app/modules/items/ui/Item/SupplierParts/SupplierParts.tsx +++ b/apps/erp/app/modules/items/ui/Item/SupplierParts/SupplierParts.tsx @@ -1,9 +1,10 @@ -import { Card, CardContent, CardHeader, CardTitle, cn } from "@carbon/react"; +import { MenuIcon, MenuItem } from "@carbon/react"; import type { ColumnDef } from "@tanstack/react-table"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import { LuPencil } from "react-icons/lu"; import { Outlet, useNavigate } from "react-router"; -import { SupplierAvatar } from "~/components"; -import Grid from "~/components/Grid"; +import { New, SupplierAvatar } from "~/components"; +import Table from "~/components/Table"; import { useCurrencyFormatter } from "~/hooks"; import { useCustomColumns } from "~/hooks/useCustomColumns"; import type { SupplierPart } from "../../../types"; @@ -98,22 +99,28 @@ const SupplierParts = ({ return [...defaultColumns, ...customColumns]; }, [customColumns, formatter]); + const renderContextMenu = useCallback( + (row: Part) => ( + navigate(row.id!)}> + } /> + Edit Supplier Part + + ), + [navigate] + ); + return ( <> - - - Supplier Parts - - - - contained={false} - data={supplierParts} - columns={columns} - onEditRow={(row) => navigate(row.id!)} - onNewRow={canEdit ? () => navigate("new") : undefined} - /> - - + + data={supplierParts} + columns={columns} + count={supplierParts.length} + title="Supplier Parts" + renderContextMenu={canEdit ? renderContextMenu : undefined} + primaryAction={ + canEdit ? : undefined + } + /> ); diff --git a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx index da3a5ff5d8..c5152dc382 100644 --- a/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx +++ b/apps/erp/app/modules/purchasing/ui/SupplierQuote/SupplierQuoteCompareDrawer.tsx @@ -199,7 +199,10 @@ const SupplierQuoteCompareDrawer = ({ {step === "compare" ? "Compare Supplier Quotes" - : `Create Order from ${selectedQuote?.supplier?.name ?? selectedQuote?.supplierQuoteId}`} + : `Create Order from ${ + selectedQuote?.supplier?.name ?? + selectedQuote?.supplierQuoteId + }`} {step === "compare" && totalQuotes > 0 && ( diff --git a/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx index 964d96b531..0de9e5985b 100644 --- a/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx +++ b/apps/erp/app/routes/x+/consumable+/$itemId.purchasing.$supplierPartId.tsx @@ -1,7 +1,7 @@ import { assertIsPost, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; -import { validator } from "@carbon/form"; +import { validationError, validator } from "@carbon/form"; import { useRouteData } from "@carbon/remix"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; @@ -13,8 +13,7 @@ import { path } from "~/utils/path"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, { - view: "parts", - role: "employee" + view: "parts" }); const { supplierPartId } = params; @@ -35,8 +34,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); const { client, userId } = await requirePermissions(request, { - update: "parts", - role: "employee" + update: "parts" }); const { itemId, supplierPartId } = params; @@ -47,7 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const validation = await validator(supplierPartValidator).validate(formData); if (validation.error) { - return { success: false, message: "Invalid form data" }; + return validationError(validation.error); } // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration diff --git a/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx index 4238b77415..b3952e41b0 100644 --- a/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx +++ b/apps/erp/app/routes/x+/material+/$itemId.purchasing.$supplierPartId.tsx @@ -1,7 +1,7 @@ import { assertIsPost, success } from "@carbon/auth"; import { requirePermissions } from "@carbon/auth/auth.server"; import { flash } from "@carbon/auth/session.server"; -import { validator } from "@carbon/form"; +import { validationError, validator } from "@carbon/form"; import { useRouteData } from "@carbon/remix"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { redirect, useLoaderData, useNavigate, useParams } from "react-router"; @@ -13,8 +13,7 @@ import { path } from "~/utils/path"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, { - view: "parts", - role: "employee" + view: "parts" }); const { supplierPartId } = params; @@ -35,8 +34,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); const { client, userId } = await requirePermissions(request, { - update: "parts", - role: "employee" + update: "parts" }); const { itemId, supplierPartId } = params; @@ -47,7 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const validation = await validator(supplierPartValidator).validate(formData); if (validation.error) { - return { success: false, message: "Invalid form data" }; + return validationError(validation.error); } // biome-ignore lint/correctness/noUnusedVariables: suppressed due to migration diff --git a/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx index 5e07b00eb5..4fbeb2a876 100644 --- a/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx +++ b/apps/erp/app/routes/x+/part+/$itemId.purchasing.$supplierPartId.tsx @@ -13,8 +13,7 @@ import { path } from "~/utils/path"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, { - view: "parts", - role: "employee" + view: "parts" }); const { supplierPartId } = params; @@ -35,8 +34,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); const { client, userId } = await requirePermissions(request, { - update: "parts", - role: "employee" + update: "parts" }); const { itemId, supplierPartId } = params; diff --git a/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx b/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx index 3bbfbde320..0d51fec0dc 100644 --- a/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx +++ b/apps/erp/app/routes/x+/tool+/$itemId.purchasing.$supplierPartId.tsx @@ -13,8 +13,7 @@ import { path } from "~/utils/path"; export async function loader({ request, params }: LoaderFunctionArgs) { const { client, companyId } = await requirePermissions(request, { - view: "parts", - role: "employee" + view: "parts" }); const { supplierPartId } = params; @@ -35,8 +34,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { export async function action({ request, params }: ActionFunctionArgs) { assertIsPost(request); const { client, userId } = await requirePermissions(request, { - update: "parts", - role: "employee" + update: "parts" }); const { itemId, supplierPartId } = params; diff --git a/apps/erp/app/utils/path.ts b/apps/erp/app/utils/path.ts index 6b895f4168..43eb05f74c 100644 --- a/apps/erp/app/utils/path.ts +++ b/apps/erp/app/utils/path.ts @@ -478,7 +478,7 @@ export const path = { generatePath(`${x}/consumable/${id}/purchasing`), consumableRoot: `${x}/consumable`, consumableSupplier: (itemId: string, id: string) => - generatePath(`${x}/consumable/${itemId}/suppliers/${id}`), + generatePath(`${x}/consumable/${itemId}/purchasing/${id}`), consumableSuppliers: (id: string) => generatePath(`${x}/consumable/${id}/suppliers`), convertQuoteToOrder: (id: string) => @@ -965,7 +965,7 @@ export const path = { generatePath(`${x}/material/${id}/purchasing`), materialRoot: `${x}/material`, materialSupplier: (itemId: string, id: string) => - generatePath(`${x}/material/${itemId}/suppliers/${id}`), + generatePath(`${x}/material/${itemId}/purchasing/${id}`), materialSuppliers: (id: string) => generatePath(`${x}/material/${id}/suppliers`), materials: `${x}/items/materials`, @@ -1178,7 +1178,7 @@ export const path = { partRoot: `${x}/part`, partSales: (id: string) => generatePath(`${x}/part/${id}/sales`), partSupplier: (itemId: string, id: string) => - generatePath(`${x}/part/${itemId}/view/purchasing/${id}`), + generatePath(`${x}/part/${itemId}/purchasing/${id}`), parts: `${x}/items/parts`, partner: (id: string, abilityId: string) => generatePath(`${x}/resources/partners/${id}/${abilityId}`), @@ -1569,7 +1569,7 @@ export const path = { toolPurchasing: (id: string) => generatePath(`${x}/tool/${id}/purchasing`), toolRoot: `${x}/tool`, toolSupplier: (itemId: string, id: string) => - generatePath(`${x}/tool/${itemId}/view/purchasing/${id}`), + generatePath(`${x}/tool/${itemId}/purchasing/${id}`), toolSuppliers: (id: string) => generatePath(`${x}/tool/${id}/view/suppliers`), tools: `${x}/items/tools`, diff --git a/packages/database/src/types.ts b/packages/database/src/types.ts index 44efbea60c..0420ffd81d 100644 --- a/packages/database/src/types.ts +++ b/packages/database/src/types.ts @@ -52069,14 +52069,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] diff --git a/packages/database/supabase/functions/lib/types.ts b/packages/database/supabase/functions/lib/types.ts index 44efbea60c..0420ffd81d 100644 --- a/packages/database/supabase/functions/lib/types.ts +++ b/packages/database/supabase/functions/lib/types.ts @@ -52069,14 +52069,14 @@ export type Database = { Relationships: [ { foreignKeyName: "address_countryCode_fkey" - columns: ["customerCountryCode"] + columns: ["invoiceCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"] }, { foreignKeyName: "address_countryCode_fkey" - columns: ["invoiceCountryCode"] + columns: ["customerCountryCode"] isOneToOne: false referencedRelation: "country" referencedColumns: ["alpha2"]