diff --git a/api/src/api/procurement/[id]/inventoryByWarehouse.ts b/api/src/api/procurement/[id]/inventoryByWarehouse.ts new file mode 100644 index 0000000..9d978bf --- /dev/null +++ b/api/src/api/procurement/[id]/inventoryByWarehouse.ts @@ -0,0 +1,52 @@ +import { createRoute } from "../../../modules/api-utils/createRoute"; +import { procurement } from "../../../managers/procurement"; +import { apiResponse } from "../../../modules/api-utils/apiResponse"; +import { prisma } from "../../../constants"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { authMiddleware } from "../../middleware/authorization"; + +/* GET /v1/procurement/items/:identifier/inventory */ +export default createRoute( + "get", + ["/items/:identifier/inventory"], + async (c) => { + const identifier = c.req.param("identifier"); + const includeWarehouse = c.req.query("includeWarehouse") === "true"; + const includeWarehouseBin = c.req.query("includeWarehouseBin") === "true"; + const item = await procurement.fetchItem(identifier); + + const rows = await prisma.productInventory.findMany({ + where: { itemId: item.cwCatalogId }, + include: { + warehouse: includeWarehouse, + warehouseBin: includeWarehouseBin, + }, + orderBy: [{ warehouseId: "asc" }, { warehouseBinId: "asc" }], + }); + + const data = rows.map((row) => ({ + id: row.id, + qtyOnHand: row.qtyOnHand, + warehouseId: row.warehouseId, + warehouseBinId: row.warehouseBinId, + ...(includeWarehouse && { + warehouse: (row as any).warehouse + ? { id: (row as any).warehouse.id, name: (row as any).warehouse.name } + : null, + }), + ...(includeWarehouseBin && { + warehouseBin: (row as any).warehouseBin + ? { id: (row as any).warehouseBin.id, name: (row as any).warehouseBin.name } + : null, + }), + })); + + const response = apiResponse.successful( + "Product inventory fetched successfully!", + data, + ); + + return c.json(response, response.status as ContentfulStatusCode); + }, + authMiddleware({ permissions: ["procurement.catalog.fetch"] }), +); diff --git a/api/src/api/procurement/index.ts b/api/src/api/procurement/index.ts index 2f7a87e..8eeb90a 100644 --- a/api/src/api/procurement/index.ts +++ b/api/src/api/procurement/index.ts @@ -1,6 +1,7 @@ import { default as fetchAll } from "./fetchAll"; import { default as fetch } from "./[id]/fetch"; import { default as refreshInventory } from "./[id]/refreshInventory"; +import { default as inventoryByWarehouse } from "./[id]/inventoryByWarehouse"; import { default as link } from "./[id]/link"; import { default as unlink } from "./[id]/unlink"; import { default as fetchLinked } from "./[id]/fetchLinked"; @@ -15,6 +16,7 @@ export { fetchAll, fetchLinked, filters, + inventoryByWarehouse, link, refreshInventory, unlink, diff --git a/api/src/api/sales/opportunities/[id]/update.ts b/api/src/api/sales/opportunities/[id]/update.ts index 5578c21..4e342cf 100644 --- a/api/src/api/sales/opportunities/[id]/update.ts +++ b/api/src/api/sales/opportunities/[id]/update.ts @@ -9,12 +9,14 @@ import { OpportunityStatus, StatusIdToKey, } from "../../../../workflows/wf.opportunity"; +import { resolveCwProbabilityId } from "../../../../modules/cw-utils/opportunities/cwProbabilityCache"; const updateSchema = z .object({ name: z.string().min(1).optional(), notes: z.string().optional(), interest: z.enum(["HOT", "WARM", "COLD"]).nullable().optional(), + probability: z.number().min(0).max(100).optional(), rating: z.object({ id: z.number() }).optional(), type: z.object({ id: z.number() }).optional(), stage: z.object({ id: z.number() }).optional(), @@ -66,7 +68,19 @@ export default createRoute( } try { - const updated = await item.updateOpportunity(data); + const { probability: probabilityPercent, ...rest } = data; + + // Resolve numeric probability → CW reference ID + let probabilityRef: { id: number } | undefined; + if (probabilityPercent !== undefined) { + const probId = await resolveCwProbabilityId(probabilityPercent); + if (probId != null) probabilityRef = { id: probId }; + } + + const updated = await item.updateOpportunity({ + ...rest, + ...(probabilityRef !== undefined ? { probability: probabilityRef } : {}), + }); const response = apiResponse.successful( "Opportunity updated successfully!", diff --git a/api/src/api/sales/opportunities/[id]/workflow/history.ts b/api/src/api/sales/opportunities/[id]/workflow/history.ts index 765c1a2..1564596 100644 --- a/api/src/api/sales/opportunities/[id]/workflow/history.ts +++ b/api/src/api/sales/opportunities/[id]/workflow/history.ts @@ -7,6 +7,7 @@ import { timeEntries } from "../../../../../managers/timeEntries"; import { activityCw } from "../../../../../modules/cw-utils/activities/activities"; import { ActivityController } from "../../../../../controllers/ActivityController"; import { OptimaType } from "../../../../../workflows/wf.opportunity"; +import { prisma } from "../../../../../constants"; // ═══════════════════════════════════════════════════════════════════════════ // HELPERS @@ -168,13 +169,47 @@ export default createRoute( }), ); + // Resolve CwMember info for all unique memberIds across time entries + const allMemberIds = Array.from( + new Set( + activitiesWithTimeEntries.flatMap((item) => + item.timeEntries + .map((te) => te.memberId) + .filter((id): id is string => !!id), + ), + ), + ); + + const memberRecords = allMemberIds.length + ? await prisma.cwMember.findMany({ + where: { identifier: { in: allMemberIds } }, + select: { identifier: true, firstName: true, lastName: true, officeEmail: true }, + }) + : []; + + const memberMap = new Map( + memberRecords.map((m) => [ + m.identifier, + { name: `${m.firstName} ${m.lastName}`.trim(), email: m.officeEmail ?? null }, + ]), + ); + + // Attach member info to each time entry + const enrichedActivities = activitiesWithTimeEntries.map((item) => ({ + ...item, + timeEntries: item.timeEntries.map((te) => ({ + ...te, + member: te.memberId ? (memberMap.get(te.memberId) ?? null) : null, + })), + })); + const response = apiResponse.successful( "Workflow history fetched successfully.", { opportunityId: opportunity.id, cwOpportunityId: opportunity.cwOpportunityId, - totalActivities: activitiesWithTimeEntries.length, - activities: activitiesWithTimeEntries, + totalActivities: enrichedActivities.length, + activities: enrichedActivities, }, ); return c.json(response, response.status as ContentfulStatusCode); diff --git a/api/src/api/sales/opportunities/create.ts b/api/src/api/sales/opportunities/create.ts index b9be3ec..79dbded 100644 --- a/api/src/api/sales/opportunities/create.ts +++ b/api/src/api/sales/opportunities/create.ts @@ -11,6 +11,7 @@ import { createWorkflowActivity, OptimaType, } from "../../../workflows/wf.opportunity"; +import { resolveCwProbabilityId } from "../../../modules/cw-utils/opportunities/cwProbabilityCache"; const createSchema = z.object({ name: z.string().min(1), @@ -46,16 +47,25 @@ export default createRoute( const data = createSchema.parse(body); const { interest, ...cwCreateData } = data; - try { - const item = await opportunities.createItem(cwCreateData); + // Resolve the CW probability reference ID for 50% (the default) + const defaultProbabilityId = await resolveCwProbabilityId(50); - if (interest !== undefined) { - await prisma.opportunity.update({ - where: { uid: item.id }, - data: { interest }, - }); - item.interest = interest; - } + try { + const item = await opportunities.createItem({ + ...cwCreateData, + ...(defaultProbabilityId != null + ? { probability: { id: defaultProbabilityId } } + : {}), + }); + + // Apply defaults: "HOT" interest and 50% probability if not explicitly provided. + const effectiveInterest = interest !== undefined ? interest : "HOT"; + await prisma.opportunity.update({ + where: { uid: item.id }, + data: { interest: effectiveInterest, probability: 50 }, + }); + item.interest = effectiveInterest; + item.probability = 50; // Create a workflow activity for the new opportunity try { diff --git a/api/src/controllers/CatalogItemController.ts b/api/src/controllers/CatalogItemController.ts index 6ed109c..d9954bc 100644 --- a/api/src/controllers/CatalogItemController.ts +++ b/api/src/controllers/CatalogItemController.ts @@ -5,7 +5,6 @@ import { CatalogManufacturer, } from "../../generated/prisma/client"; import { prisma } from "../constants"; -import { catalogCw } from "../modules/cw-utils/procurement/catalog"; import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types"; import GenericError from "../Errors/GenericError"; @@ -129,7 +128,11 @@ export class CatalogItemController { * @returns {Promise} - The updated controller */ public async refreshInventory(): Promise { - const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId); + const result = await prisma.productInventory.aggregate({ + where: { itemId: this.cwCatalogId }, + _sum: { qtyOnHand: true }, + }); + const onHand = result._sum.qtyOnHand ?? 0; if (onHand !== this.onHand) { await prisma.catalogItem.update({ diff --git a/api/src/controllers/OpportunityController.ts b/api/src/controllers/OpportunityController.ts index fdff27d..e4b5195 100644 --- a/api/src/controllers/OpportunityController.ts +++ b/api/src/controllers/OpportunityController.ts @@ -1904,7 +1904,7 @@ export class OpportunityController { expectedSalesTaxRate: this.taxCodeRate !== null ? this.taxCodeRate * 100 : null, taxCodeDescription: this.taxCodeDescription, - probability: this.probability, + probability: this.probability != null ? { percent: this.probability } : null, location: this.locationCwId ? { id: this.locationCwId, name: this.locationName } : null, diff --git a/api/src/modules/cw-utils/opportunities/cwProbabilityCache.ts b/api/src/modules/cw-utils/opportunities/cwProbabilityCache.ts new file mode 100644 index 0000000..3ac8c5a --- /dev/null +++ b/api/src/modules/cw-utils/opportunities/cwProbabilityCache.ts @@ -0,0 +1,63 @@ +/** + * cwProbabilityCache + * + * Fetches and caches the list of ConnectWise probability dropdown options. + * Used to resolve a numeric percent (0–100) to the CW probability reference ID + * required when creating or updating opportunities via the REST API. + * + * CW endpoint: GET /sales/probabilities + * Returns: [{ id: number, probability: number }] + */ + +import { connectWiseApi } from "../../../constants"; + +interface CWProbability { + id: number; + probability: number; +} + +let _cache: CWProbability[] | null = null; + +async function fetchProbabilities(): Promise { + if (_cache) return _cache; + const response = await connectWiseApi.get( + "/sales/probabilities", + { params: { pageSize: 1000 } } + ); + _cache = response.data; + return _cache; +} + +/** + * Resolve the CW probability reference ID for a given percent value. + * + * Finds an exact match first; if none, returns the closest option. + * Returns null if the probabilities list is empty. + */ +export async function resolveCwProbabilityId( + percent: number +): Promise { + const list = await fetchProbabilities(); + if (list.length === 0) return null; + + // Exact match + const exact = list.find((p) => p.probability === percent); + if (exact) return exact.id; + + // Closest match + let closest = list[0]!; + let minDiff = Math.abs(closest.probability - percent); + for (const option of list) { + const diff = Math.abs(option.probability - percent); + if (diff < minDiff) { + minDiff = diff; + closest = option; + } + } + return closest.id; +} + +/** Clear the cache (useful for tests or forced refresh). */ +export function clearCwProbabilityCache(): void { + _cache = null; +} diff --git a/api/src/modules/cw-utils/opportunities/opportunity.types.ts b/api/src/modules/cw-utils/opportunities/opportunity.types.ts index fb1975b..7cc23ee 100644 --- a/api/src/modules/cw-utils/opportunities/opportunity.types.ts +++ b/api/src/modules/cw-utils/opportunities/opportunity.types.ts @@ -272,6 +272,7 @@ export interface CWOpportunityUpdate { stage?: { id: number }; status?: { id: number }; priority?: { id: number }; + probability?: { id: number }; campaign?: { id: number }; primarySalesRep?: { id: number }; secondarySalesRep?: { id: number } | null; @@ -296,6 +297,7 @@ export interface CWOpportunityCreate { stage?: { id: number }; status?: { id: number }; priority?: { id: number }; + probability?: { id: number }; campaign?: { id: number }; secondarySalesRep?: { id: number } | null; site?: { id: number } | null; diff --git a/dalpuri/src/index.ts b/dalpuri/src/index.ts index 56e699e..e360a7d 100644 --- a/dalpuri/src/index.ts +++ b/dalpuri/src/index.ts @@ -11,6 +11,7 @@ export { catalogCategoryTranslation } from "./translations/catalog-category"; export { catalogSubcategoryTranslation } from "./translations/catalog-subcategory"; export { catalogManufacturerTranslation } from "./translations/catalog-manufacturer"; export { warehouseBinTranslation } from "./translations/warehouse-bin"; +export { warehouseTranslation } from "./translations/warehouse"; export { productInventoryTranslation } from "./translations/product-inventory"; export { productDataTranslation } from "./translations/product-data.ts"; export { corporateLocationTranslation } from "./translations/corporate-location"; diff --git a/dalpuri/src/sync-by-table.ts b/dalpuri/src/sync-by-table.ts index d073d5b..acd3083 100644 --- a/dalpuri/src/sync-by-table.ts +++ b/dalpuri/src/sync-by-table.ts @@ -45,6 +45,7 @@ import { activityNotesTranslation, userTranslation, warehouseBinTranslation, + warehouseTranslation, type TranslationContext, } from "./index"; import { Translation, SkipRowError } from "./translations/types"; @@ -693,6 +694,13 @@ const getConfigForTable = (table: string): SyncTableConfig | null => { uniqueField: "id", lastUpdatedField: "lastUpdatedUtc", }, + warehouse: { + sourceModel: "warehouse", + targetModel: "warehouse", + translation: warehouseTranslation as unknown as AnyTranslation, + uniqueField: "id", + lastUpdatedField: "lastUpdatedUtc", + }, warehouseBin: { sourceModel: "warehouseBin", targetModel: "warehouseBin", @@ -800,6 +808,11 @@ const getConfigForTable = (table: string): SyncTableConfig | null => { closedFlag: true, }, }, + soInterest: { + select: { + description: true, + }, + }, }, }, }, diff --git a/dalpuri/src/sync.ts b/dalpuri/src/sync.ts index 82c318b..c3786e4 100644 --- a/dalpuri/src/sync.ts +++ b/dalpuri/src/sync.ts @@ -51,6 +51,7 @@ import { cwMemberTypeTranslation, userTranslation, warehouseBinTranslation, + warehouseTranslation, type TranslationContext, } from "./index"; import { Translation, SkipRowError } from "./translations/types"; @@ -1669,6 +1670,15 @@ export const executeFullDalpuriSync = async (options?: { sourceIdField: "manufacturerRecId", sourceUpdatedField: "lastUpdatedUtc", }, + { + name: "Warehouses", + sourceModel: "warehouse", + targetModel: "warehouse", + translation: warehouseTranslation as unknown as AnyTranslation, + uniqueField: "id", + sourceIdField: "warehouseRecId", + sourceUpdatedField: "lastUpdatedUtc", + }, { name: "Warehouse Bins", sourceModel: "warehouseBin", @@ -1827,6 +1837,11 @@ export const executeFullDalpuriSync = async (options?: { closedFlag: true, }, }, + soInterest: { + select: { + description: true, + }, + }, }, }, }, @@ -2112,6 +2127,33 @@ export const executeFullDalpuriSync = async (options?: { `${step.name}: upserted=${result.insertedOrUpdated} skipped=${result.skipped} failed=${result.failed}` ); + // After syncing product inventory, recalculate CatalogItem.onHand for all items + // by summing all ProductInventory.qtyOnHand rows grouped by itemId. + if (step.targetModel === "productInventory") { + console.log(" [post-step] Recalculating CatalogItem.onHand from ProductInventory..."); + const grouped = await apiPrisma.productInventory.groupBy({ + by: ["itemId"], + _sum: { qtyOnHand: true }, + where: { itemId: { not: null } }, + }); + for (const group of grouped) { + if (group.itemId == null) continue; + await apiPrisma.catalogItem.updateMany({ + where: { id: group.itemId }, + data: { onHand: group._sum.qtyOnHand ?? 0 }, + }); + } + // Zero out items that have no inventory rows + const itemIdsWithInventory = grouped + .map((g) => g.itemId) + .filter((id): id is number => id != null); + await apiPrisma.catalogItem.updateMany({ + where: { id: { notIn: itemIdsWithInventory } }, + data: { onHand: 0 }, + }); + console.log(` [post-step] Updated onHand for ${grouped.length} catalog items.`); + } + await writeStepLog( step.name, effectiveDecision.mode, diff --git a/dalpuri/src/translations/catalog-item.ts b/dalpuri/src/translations/catalog-item.ts index d6173d1..29a6630 100644 --- a/dalpuri/src/translations/catalog-item.ts +++ b/dalpuri/src/translations/catalog-item.ts @@ -45,11 +45,6 @@ export const catalogItemTranslation: Translation< }, { from: "inactiveFlag", to: "inactive" }, { from: "taxableFlag", to: "salesTaxable" }, - { - from: "minimumStock", - to: "onHand", - process: (value) => (value == null ? 0 : value), - }, { from: "classId", to: "classId" }, { from: "dateEnteredUtc", to: "createdAt" }, { from: "lastUpdatedUtc", to: "cwLastUpdated" }, diff --git a/dalpuri/src/translations/opportunity.ts b/dalpuri/src/translations/opportunity.ts index 24ccb7d..7ffd68f 100644 --- a/dalpuri/src/translations/opportunity.ts +++ b/dalpuri/src/translations/opportunity.ts @@ -45,13 +45,19 @@ type CwOpportunityWithMembers = CwOpportunity & { "memberRecId" | "primarySalesFlag" | "secondarySalesFlag" >[]; soOppStatus?: Pick | null; + soInterest?: { description: string | null } | null; }; -const toInterest = (value: number | null): OpportunityInterest | null => { +const toInterest = ( + value: { description: string | null } | null +): OpportunityInterest | null => { if (value == null) return null; - if (value <= 1) return OpportunityInterest.COLD; - if (value === 2) return OpportunityInterest.WARM; - return OpportunityInterest.HOT; + const desc = value.description?.toLowerCase() ?? ""; + if (desc.includes("cold")) return OpportunityInterest.COLD; + if (desc.includes("warm") || desc.includes("medium")) + return OpportunityInterest.WARM; + if (desc.includes("hot")) return OpportunityInterest.HOT; + return null; }; export const opportunityTranslation: Translation< @@ -85,7 +91,7 @@ export const opportunityTranslation: Translation< { from: "soOppStatusRecId", to: "statusId" }, { from: "taxCodeRecId", to: "taxCodeId" }, { - from: "soInterestRecId", + from: "soInterest", to: "interest", process: toInterest, }, diff --git a/dalpuri/src/translations/product-inventory.ts b/dalpuri/src/translations/product-inventory.ts index f3f42e7..f537bd2 100644 --- a/dalpuri/src/translations/product-inventory.ts +++ b/dalpuri/src/translations/product-inventory.ts @@ -18,6 +18,7 @@ export const productInventoryTranslation: Translation< to: "createdAt", process: (value) => (value ? value : new Date(0)), }, + { from: "warehouseRecId", to: "warehouseId" }, { from: "warehouseBinRecId", to: "warehouseBinId" }, { from: "catalogRecId", to: "itemId" }, { from: "updatedBy", to: "updatedById" }, diff --git a/dalpuri/src/translations/warehouse-bin.ts b/dalpuri/src/translations/warehouse-bin.ts index 1f76cd5..0c6e09b 100644 --- a/dalpuri/src/translations/warehouse-bin.ts +++ b/dalpuri/src/translations/warehouse-bin.ts @@ -34,6 +34,7 @@ export const warehouseBinTranslation: Translation< to: "defaultFlag", process: (value) => Boolean(value), }, + { from: "warehouseRecId", to: "warehouseId" }, { from: "updatedBy", to: "updatedById" }, { from: "enteredBy", to: "createdById" }, { from: "lastUpdatedUtc", to: "updatedAt" }, diff --git a/dalpuri/src/translations/warehouse.ts b/dalpuri/src/translations/warehouse.ts new file mode 100644 index 0000000..6a240d7 --- /dev/null +++ b/dalpuri/src/translations/warehouse.ts @@ -0,0 +1,28 @@ +import { Warehouse as CwWarehouse } from "../../generated/prisma/client"; +import { Warehouse as ApiWarehouse } from "../../../api/generated/prisma/client"; +import { Translation } from "./types"; + +export const warehouseTranslation: Translation = { + values: [ + { from: "warehouseRecId", to: "id" }, + { + from: "warehouseName", + to: "name", + process: (value) => (value ? value : "Unnamed Warehouse"), + }, + { + from: "inactiveFlag", + to: "inactiveFlag", + process: (value) => Boolean(value), + }, + { + from: "lockedFlag", + to: "lockedFlag", + process: (value) => Boolean(value), + }, + { from: "updatedBy", to: "updatedById" }, + { from: "enteredBy", to: "createdById" }, + { from: "lastUpdatedUtc", to: "updatedAt" }, + { from: "dateEnteredUtc", to: "createdAt" }, + ], +}; diff --git a/ui/src/components/AddProductModal.svelte b/ui/src/components/AddProductModal.svelte index 114b3d2..a5663fd 100644 --- a/ui/src/components/AddProductModal.svelte +++ b/ui/src/components/AddProductModal.svelte @@ -14,6 +14,7 @@ type LaborStyle, } from "$lib/optima-api/modules/sales"; import EditGuard from "./EditGuard.svelte"; + import InventoryPopover from "./InventoryPopover.svelte"; export let isOpen = false; export let accessToken: string; @@ -2669,14 +2670,8 @@ >{formatPrice(item.price)} {/if} - {#if item.onHand != null && item.onHand > 0} - {item.onHand} in stock - {:else if item.onHand != null} - Out of stock + {#if item.onHand != null} + {/if} {#if cart.some((c) => c.id === item.id)} @@ -3081,17 +3076,7 @@
Inventory - {#if detailItem.onHand != null && detailItem.onHand > 0} - {detailItem.onHand} on hand - {:else if detailItem.onHand != null} - Out of stock - {:else} - N/A - {/if} +
diff --git a/ui/src/components/InventoryPopover.svelte b/ui/src/components/InventoryPopover.svelte new file mode 100644 index 0000000..638c348 --- /dev/null +++ b/ui/src/components/InventoryPopover.svelte @@ -0,0 +1,273 @@ + + +
+ 0 && onHand <= 3} + class:onhand-ok={onHand != null && onHand > 3} + > + {onHand ?? "—"} + +
+ +{#if visible} + +{/if} + + diff --git a/ui/src/components/OpportunityContextMenu.svelte b/ui/src/components/OpportunityContextMenu.svelte index 120f60d..8efb78f 100644 --- a/ui/src/components/OpportunityContextMenu.svelte +++ b/ui/src/components/OpportunityContextMenu.svelte @@ -118,9 +118,9 @@ const ACTION_LABELS: Partial> = { requestReview: "Request Review", reviewDecision: "Complete Review", - sendQuote: "Quote Sent", + sendQuote: "Mark Quote Sent", ReadyToSend: "Ready to Send", - resendQuote: "Quote Resent", + resendQuote: "Mark Quote Resent", confirmQuote: "Quote Confirmed", beginRevision: "Begin Revising", resurrect: "Resurrect", diff --git a/ui/src/lib/optima-api/modules/procurement.ts b/ui/src/lib/optima-api/modules/procurement.ts index 78c32ef..b419c09 100644 --- a/ui/src/lib/optima-api/modules/procurement.ts +++ b/ui/src/lib/optima-api/modules/procurement.ts @@ -146,6 +146,19 @@ export const procurement = { return response.data.data.count; }, + async fetchInventory(accessToken: string, identifier: string) { + const response = await api.get( + `/v1/procurement/items/${identifier}/inventory`, + { + params: { includeWarehouse: true }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + async refreshInventory(accessToken: string, identifier: string) { const response = await api.post( `/v1/procurement/items/${identifier}/refresh-inventory`, diff --git a/ui/src/lib/optima-api/modules/sales.ts b/ui/src/lib/optima-api/modules/sales.ts index 7cfe357..a338add 100644 --- a/ui/src/lib/optima-api/modules/sales.ts +++ b/ui/src/lib/optima-api/modules/sales.ts @@ -210,6 +210,7 @@ export interface WorkflowTimeEntry { id: string; cwId: number; memberId?: string | null; + member?: { name: string; email: string | null } | null; dateStart?: string | null; timeStart?: string | null; timeEnd?: string | null; diff --git a/ui/src/routes/procurement/catalog/+page.svelte b/ui/src/routes/procurement/catalog/+page.svelte index 49bd193..82bde59 100644 --- a/ui/src/routes/procurement/catalog/+page.svelte +++ b/ui/src/routes/procurement/catalog/+page.svelte @@ -6,6 +6,7 @@ import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte"; import AccessDenied from "../../../components/AccessDenied.svelte"; import Pagination from "../../../components/Pagination.svelte"; + import InventoryPopover from "../../../components/InventoryPopover.svelte"; import { formatDate } from "$lib/utils"; import "../../../styles/procurement/catalog.css"; import { clientFetch } from "$lib/client-fetch"; @@ -598,16 +599,7 @@ {formatCurrency(item.price)} {formatCurrency(item.cost)} - 0 && - item.onHand <= 3} - class:onhand-ok={item.onHand != null && item.onHand > 3} - > - {item.onHand ?? "—"} - + On Hand - 0 && - selectedItem.onHand <= 3} - class:onhand-ok={selectedItem.onHand != null && - selectedItem.onHand > 3} - > - {selectedItem.onHand ?? "—"} - +
@@ -966,18 +948,7 @@
On Hand - 0 && - li.onHand <= 3} - class:onhand-ok={li.onHand != null && - li.onHand > 3} - > - {li.onHand ?? "—"} - - +
Status @@ -1332,17 +1303,7 @@ {#if linkPreviewItem.manufacturer} diff --git a/ui/src/routes/procurement/catalog/inventory/+server.ts b/ui/src/routes/procurement/catalog/inventory/+server.ts new file mode 100644 index 0000000..922dedb --- /dev/null +++ b/ui/src/routes/procurement/catalog/inventory/+server.ts @@ -0,0 +1,24 @@ +import { optima } from "$lib"; +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +/** GET /procurement/catalog/inventory?id= */ +export const GET: RequestHandler = async ({ url, locals }) => { + const accessToken = locals.session?.accessToken; + if (!accessToken) throw error(401, "Unauthorized"); + + const identifier = url.searchParams.get("id"); + if (!identifier) throw error(400, "Missing id parameter"); + + try { + const result = await optima.procurement.fetchInventory(accessToken, identifier); + return json(result); + } catch (err: unknown) { + console.error("Failed to fetch product inventory:", err); + const status = + err && typeof err === "object" && "status" in err + ? (err as { status: number }).status + : 500; + throw error(status, "Failed to fetch product inventory"); + } +}; diff --git a/ui/src/routes/sales/opportunity/[id]/+page.svelte b/ui/src/routes/sales/opportunity/[id]/+page.svelte index 41c4477..f2b7ec5 100644 --- a/ui/src/routes/sales/opportunity/[id]/+page.svelte +++ b/ui/src/routes/sales/opportunity/[id]/+page.svelte @@ -47,6 +47,8 @@ // ActivityTab ref — used to trigger a refresh after workflow actions create new activities let activityTabComponent: ActivityTab; + // NotesTab ref — refresh after workflow actions + let notesTabComponent: NotesTab; // Closed opportunity lockdown – no edits except admin delete. // Also immediately reflects Won/Lost/Canceled workflow status without needing a reload. @@ -103,6 +105,7 @@ ...(localActivities ?? opportunity?.activities ?? []), ]; activityTabComponent?.refresh(); + notesTabComponent?.refresh(); } } @@ -327,9 +330,7 @@ {#if tab === "Products" && products.length > 0} {products.length} {/if} - {#if tab === "Notes" && notes.length > 0} - {notes.length} - {/if} + {#if tab === "Quotes" && quotes.length > 0} {quotes.length} {/if} @@ -391,9 +392,7 @@ {#if tab === "Products" && products.length > 0} {products.length} {/if} - {#if tab === "Notes" && notes.length > 0} - {notes.length} - {/if} + {#if tab === "Quotes" && quotes.length > 0} {quotes.length} {/if} @@ -456,13 +455,9 @@ /> {:else if activeTab === "Notes"} { - invalidateAll(); - }} + activities={effectiveActivities} /> {:else if activeTab === "Activity"} - import { createEventDispatcher } from "svelte"; - import type { OpportunityNote } from "../types"; - import { noteAuthorInitials, formatDateTime } from "../types"; - import type { PermissionMap } from "$lib/permissions"; - import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte"; + import { onMount } from "svelte"; import { clientFetch } from "$lib/client-fetch"; + import type { + WorkflowHistoryEntry, + WorkflowTimeEntry, + OpportunityActivity, + } from "$lib/optima-api/modules/sales"; + import { formatDateTime } from "../types"; + import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte"; - export let notes: OpportunityNote[] = []; - export let permissions: PermissionMap = {} as PermissionMap; export let opportunityId: string; - export let isClosedOpportunity: boolean = false; + export let activities: OpportunityActivity[] = []; - const dispatch = createEventDispatcher(); + let workflowHistory: WorkflowHistoryEntry[] = []; + let isLoading = true; + let loadError: string | null = null; - $: canCreate = - !isClosedOpportunity && - permissions["sales.opportunity.note.create"] === true; - $: canUpdate = - !isClosedOpportunity && - permissions["sales.opportunity.note.update"] === true; - $: canDelete = - !isClosedOpportunity && - permissions["sales.opportunity.note.delete"] === true; + onMount(() => { + fetchHistory(); + }); - // ── Compose state ── - let composing = false; - let composeText = ""; - let composeFlagged = false; - let composeSaving = false; - let composeError = ""; - - // ── Edit state ── - let editingNoteId: number | null = null; - let editText = ""; - let editFlagged = false; - let editSaving = false; - let editError = ""; - - // ── Delete state ── - let deletingNoteId: number | null = null; - let deleteLoading = false; - let deleteError = ""; - - // ── Menu state ── - let openMenuId: number | null = null; - - function toggleMenu(id: number) { - openMenuId = openMenuId === id ? null : id; - } - - function handleMenuClickOutside(e: MouseEvent) { - if (openMenuId === null) return; - const target = e.target as HTMLElement; - if (target.closest(".note-menu-wrap")) return; - openMenuId = null; - } - - // ── Compose ── - function startCompose() { - composing = true; - composeText = ""; - composeFlagged = false; - composeError = ""; - } - - function cancelCompose() { - composing = false; - composeText = ""; - composeFlagged = false; - composeError = ""; - } - - async function submitNote() { - if (!composeText.trim()) return; - composeSaving = true; - composeError = ""; + async function fetchHistory() { + isLoading = true; + loadError = null; try { - await clientFetch(`/sales/opportunity/${opportunityId}/notes`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - text: composeText.trim(), - flagged: composeFlagged, - }), - }); - composing = false; - composeText = ""; - composeFlagged = false; - dispatch("notesChanged"); - } catch (err: unknown) { - composeError = - err instanceof Error ? err.message : "Failed to create note"; + const json = await clientFetch<{ + data: { activities: WorkflowHistoryEntry[] }; + }>(`/sales/opportunity/${opportunityId}/workflow/history`); + workflowHistory = json.data?.activities ?? []; + } catch (err) { + loadError = err instanceof Error ? err.message : "Failed to load notes"; } finally { - composeSaving = false; + isLoading = false; } } - // ── Edit ── - function startEdit(note: OpportunityNote) { - editingNoteId = note.id; - editText = note.text ?? ""; - editFlagged = note.flagged ?? false; - editError = ""; - openMenuId = null; + export function refresh() { + fetchHistory(); } - function cancelEdit() { - editingNoteId = null; - editText = ""; - editFlagged = false; - editError = ""; - } + // ── Derived note items from activities and time entries ── - async function submitEdit() { - if (editingNoteId === null || !editText.trim()) return; - editSaving = true; - editError = ""; - try { - await clientFetch( - `/sales/opportunity/${opportunityId}/notes/${editingNoteId}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: editText.trim(), flagged: editFlagged }), - }, - ); - editingNoteId = null; - editText = ""; - editFlagged = false; - dispatch("notesChanged"); - } catch (err: unknown) { - editError = err instanceof Error ? err.message : "Failed to update note"; - } finally { - editSaving = false; + type ActivityNoteItem = { + kind: "activity"; + hist: WorkflowHistoryEntry; + when: string; + }; + + type TimeEntryNoteItem = { + kind: "timeEntry"; + te: WorkflowTimeEntry; + optimaType: string; + when: string; + }; + + type NoteItem = ActivityNoteItem | TimeEntryNoteItem; + + $: noteItems = (() => { + let items: WorkflowHistoryEntry[]; + if (workflowHistory.length > 0) { + items = workflowHistory; + } else { + items = (activities ?? []).map((a) => ({ + activity: a, + optimaType: "", + quoteId: null, + parentActivityId: null, + closed: null as unknown as boolean, + closedAt: null, + timeEntries: [], + })); } - } - // ── Delete ── - function confirmDelete(noteId: number) { - deletingNoteId = noteId; - deleteError = ""; - openMenuId = null; - } + const result: NoteItem[] = []; - function cancelDelete() { - deletingNoteId = null; - deleteError = ""; - } + for (const hist of items) { + if (hist.activity.notes?.trim()) { + const when = hist.activity.cwDateEntered ?? hist.activity.dateStart ?? ""; + result.push({ kind: "activity", hist, when }); + } + for (const te of hist.timeEntries ?? []) { + if (te.notes?.trim()) { + const when = te.timeStart ?? te.dateStart ?? ""; + result.push({ kind: "timeEntry", te, optimaType: hist.optimaType, when }); + } + } + } - async function executeDelete() { - if (deletingNoteId === null) return; - deleteLoading = true; - deleteError = ""; - try { - await clientFetch( - `/sales/opportunity/${opportunityId}/notes/${deletingNoteId}`, - { method: "DELETE" }, - ); - deletingNoteId = null; - dispatch("notesChanged"); - } catch (err: unknown) { - deleteError = - err instanceof Error ? err.message : "Failed to delete note"; - } finally { - deleteLoading = false; - } - } + return result.sort((a, b) => b.when.localeCompare(a.when)); + })(); - function handleComposeKeydown(e: KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - submitNote(); - } - if (e.key === "Escape") { - cancelCompose(); - } - } - - function handleEditKeydown(e: KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - submitEdit(); - } - if (e.key === "Escape") { - cancelEdit(); - } + function initials(name: string | null | undefined): string { + if (!name) return "?"; + return name + .split(" ") + .map((p) => p[0]) + .join("") + .slice(0, 2) + .toUpperCase(); } - -
-
- - {notes.length} note{notes.length === 1 ? "" : "s"} - + {#if !isLoading} + + {noteItems.length} note{noteItems.length === 1 ? "" : "s"} + + {/if}
- {#if !composing} - - {/if} + + + + + Refresh +
- - {#if composing} -
- - + {#if isLoading} +
+ + Loading notes…
- {/if} - - - {#if notes.length === 0 && !composing} + {:else if loadError} +
{loadError}
+ {:else if noteItems.length === 0}
- +
{:else}
- {#each notes as note (note.id)} - {#if editingNoteId === note.id} - -
- - -
- {:else} - -
+ {#each noteItems as item} + {#if item.kind === "activity"} + {@const act = item.hist.activity} +
- {#if canUpdate || canDelete} -
- - {#if openMenuId === note.id} -
- {#if canUpdate} - - {/if} - {#if canDelete} - - {/if} -
- {/if} -
- {/if} - {#if note.type?.name} - {note.type.name} - {/if} - {#if note.flagged} - - - - {/if} -
-
-
- {note.enteredBy?.name ?? "Unknown"} - {formatDateTime(note.dateEntered)} -
- {noteAuthorInitials(note.enteredBy?.name)} + {initials(act.assignTo?.name ?? act.cwEnteredBy)}
+
+ + {act.assignTo?.name ?? act.cwEnteredBy ?? "System"} + + + {formatDateTime(act.cwDateEntered ?? act.dateStart)} + +
+
+
+ Activity + {#if item.hist.optimaType} + {item.hist.optimaType} + {/if}
-

{note.text ?? ""}

+

{act.notes ?? ""}

+
+
+ {:else} + {@const te = item.te} +
+
+
+
+ {initials(te.member?.name ?? te.memberId)} +
+
+ {te.member?.name ?? te.memberId ?? "Unknown"} + + {formatDateTime(te.timeStart ?? te.dateStart)} + +
+
+
+ Time Entry + {#if te.billableFlag} + Billable + {/if} + {#if te.actualHours != null} + {te.actualHours}h + {/if} +
+
+
+

{te.notes ?? ""}

{/if} @@ -473,48 +215,3 @@
{/if}
- - -{#if deletingNoteId !== null} - - -{/if} diff --git a/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index 15a1706..c15d2f5 100644 --- a/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/ui/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -8,6 +8,7 @@ import { optima } from "$lib"; import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte"; import AddProductModal from "../../../../../components/AddProductModal.svelte"; + import InventoryPopover from "../../../../../components/InventoryPopover.svelte"; import type { CatalogItem } from "$lib/optima-api/modules/procurement"; import type { AddProductBody, @@ -2556,7 +2557,12 @@
On Hand - {#if selectedProduct.onHand != null} + {#if selectedProduct.catalogItem} + + {:else if selectedProduct.onHand != null} > = { requestReview: "Request Review", reviewDecision: "Complete Review", - sendQuote: "Quote Sent", + sendQuote: "Mark Quote Sent", ReadyToSend: "Ready to Send", - resendQuote: "Quote Resent", + resendQuote: "Mark Quote Resent", confirmQuote: "Quote Confirmed", beginRevision: "Begin Revising", resurrect: "Resurrect", diff --git a/ui/src/styles/sales/opportunitydetail.css b/ui/src/styles/sales/opportunitydetail.css index b76310a..b6742c5 100644 --- a/ui/src/styles/sales/opportunitydetail.css +++ b/ui/src/styles/sales/opportunitydetail.css @@ -2807,7 +2807,7 @@ .note-author-info { display: flex; flex-direction: column; - align-items: flex-end; + align-items: flex-start; gap: 1px; } @@ -2856,6 +2856,43 @@ word-break: break-word; } +/* ── Note source badges (activity vs time entry) ── */ + +.note-source-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 5px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + white-space: nowrap; +} + +.note-source-activity { + background: color-mix(in srgb, #6366f1 12%, transparent); + color: #818cf8; +} + +.note-source-time-entry { + background: color-mix(in srgb, #10b981 12%, transparent); + color: #34d399; +} + +.note-card-time-entry { + border-left-color: #10b981; +} + +/* ── Card sub-header (activity name) ── */ + +.note-card-subheader { + padding: 4px 16px 0; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + /* ── Note action menu ── */ .note-menu-wrap {