feat: opportunity detail overhaul, catalog improvements, cleanup
- api: remove stray console.log debug lines across fetch routes, controllers, and workflow dispatch - api: refactor OpportunityController and cwProbabilityCache - api: add workflow history endpoint updates - ui: overhaul opportunity detail page styles and layout - ui: remove DiscountsTab (consolidated elsewhere), update ProductsTab - ui: improve catalog page with inventory popover - ui: update sales API module and server-side page loader - ui: add sleepy-cat asset, simplify NoResultsMonkey component
This commit is contained in:
@@ -20,8 +20,6 @@ export default createRoute(
|
|||||||
const includeAllContacts = c.req.query("includeAllContacts") === "true";
|
const includeAllContacts = c.req.query("includeAllContacts") === "true";
|
||||||
const includeAllAddresses = c.req.query("includeAllAddresses") === "true";
|
const includeAllAddresses = c.req.query("includeAllAddresses") === "true";
|
||||||
|
|
||||||
console.log(company.toJson({ includeAddress, includePrimaryContact, includeAllContacts }));
|
|
||||||
|
|
||||||
// Check for address-specific permission if includeAddress is requested
|
// Check for address-specific permission if includeAddress is requested
|
||||||
if (includeAddress) {
|
if (includeAddress) {
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ export default createRoute(
|
|||||||
|
|
||||||
const data = schema.parse(body);
|
const data = schema.parse(body);
|
||||||
|
|
||||||
console.log("Creating Credential Type with data:", data);
|
|
||||||
|
|
||||||
const credentialType = await credentialTypes.create(data as any);
|
const credentialType = await credentialTypes.create(data as any);
|
||||||
|
|
||||||
const response = apiResponse.created(
|
const response = apiResponse.created(
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export default createRoute(
|
|||||||
if (includes.has("products")) {
|
if (includes.has("products")) {
|
||||||
subResourcePromises.products = item
|
subResourcePromises.products = item
|
||||||
.fetchProducts()
|
.fetchProducts()
|
||||||
.then((products) => products.map((p) => p.toJson()));
|
.then((products) => {
|
||||||
|
const json = products.map((p) => p.toJson());
|
||||||
|
return json;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (includes.has("quotes")) {
|
if (includes.has("quotes")) {
|
||||||
subResourcePromises.quotes = generatedQuotes
|
subResourcePromises.quotes = generatedQuotes
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export default createRoute(
|
|||||||
if (includes.has("products")) {
|
if (includes.has("products")) {
|
||||||
subResourcePromises.products = item
|
subResourcePromises.products = item
|
||||||
.fetchProducts()
|
.fetchProducts()
|
||||||
.then((products) => products.map((p) => p.toJson()));
|
.then((products) => {
|
||||||
|
const json = products.map((p) => p.toJson());
|
||||||
|
console.log(`[PRODUCTS_DEBUG] cwOpportunityId=${item.cwOpportunityId} count=${json.length}`, JSON.stringify(json, null, 2));
|
||||||
|
return json;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (includes.has("quotes")) {
|
if (includes.has("quotes")) {
|
||||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||||
|
|||||||
@@ -116,15 +116,7 @@ export default createRoute(
|
|||||||
try {
|
try {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
console.log(
|
|
||||||
"[Workflow Dispatch] Raw request body:",
|
|
||||||
JSON.stringify(body, null, 2),
|
|
||||||
);
|
|
||||||
const parsed = dispatchSchema.parse(body);
|
const parsed = dispatchSchema.parse(body);
|
||||||
console.log(
|
|
||||||
"[Workflow Dispatch] Parsed payload:",
|
|
||||||
JSON.stringify(parsed.payload, null, 2),
|
|
||||||
);
|
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
|
|
||||||
// ── Resolve opportunity ────────────────────────────────────────────
|
// ── Resolve opportunity ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -182,9 +182,9 @@ export default createRoute(
|
|||||||
|
|
||||||
const memberRecords = allMemberIds.length
|
const memberRecords = allMemberIds.length
|
||||||
? await prisma.cwMember.findMany({
|
? await prisma.cwMember.findMany({
|
||||||
where: { identifier: { in: allMemberIds } },
|
where: { identifier: { in: allMemberIds } },
|
||||||
select: { identifier: true, firstName: true, lastName: true, officeEmail: true },
|
select: { identifier: true, firstName: true, lastName: true, officeEmail: true },
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const memberMap = new Map(
|
const memberMap = new Map(
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ async function resolveMember(identifier: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
return member
|
return member
|
||||||
? {
|
? {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
identifier: member.identifier,
|
identifier: member.identifier,
|
||||||
name: `${member.firstName} ${member.lastName}`.trim(),
|
name: `${member.firstName} ${member.lastName}`.trim(),
|
||||||
cwMemberId: member.cwMemberId,
|
cwMemberId: member.cwMemberId,
|
||||||
}
|
}
|
||||||
: { id: null, identifier, name: identifier, cwMemberId: null };
|
: { id: null, identifier, name: identifier, cwMemberId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +193,8 @@ export class OpportunityController {
|
|||||||
});
|
});
|
||||||
const resolvedName = user
|
const resolvedName = user
|
||||||
? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
|
? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
|
||||||
user.login ||
|
user.login ||
|
||||||
user.email
|
user.email
|
||||||
: null;
|
: null;
|
||||||
const name =
|
const name =
|
||||||
resolvedName ??
|
resolvedName ??
|
||||||
@@ -209,8 +209,8 @@ export class OpportunityController {
|
|||||||
constructor(
|
constructor(
|
||||||
data: Opportunity & {
|
data: Opportunity & {
|
||||||
company?:
|
company?:
|
||||||
| (Company & { contacts?: any[]; companyAddresses?: any[] })
|
| (Company & { contacts?: any[]; companyAddresses?: any[] })
|
||||||
| null;
|
| null;
|
||||||
primarySalesRep?: (User & { roles: Role[] }) | null;
|
primarySalesRep?: (User & { roles: Role[] }) | null;
|
||||||
secondarySalesRep?: (User & { roles: Role[] }) | null;
|
secondarySalesRep?: (User & { roles: Role[] }) | null;
|
||||||
},
|
},
|
||||||
@@ -222,8 +222,6 @@ export class OpportunityController {
|
|||||||
activities?: ActivityController[];
|
activities?: ActivityController[];
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
console.log(data.primarySalesRep);
|
|
||||||
|
|
||||||
// New schema: uid is the internal PK (string), id is the CW opportunity ID (Int)
|
// New schema: uid is the internal PK (string), id is the CW opportunity ID (Int)
|
||||||
this.id = data.uid;
|
this.id = data.uid;
|
||||||
this.cwOpportunityId = data.id;
|
this.cwOpportunityId = data.id;
|
||||||
@@ -515,8 +513,8 @@ export class OpportunityController {
|
|||||||
const hasCwPatch = Object.keys(cwPatch).length > 0;
|
const hasCwPatch = Object.keys(cwPatch).length > 0;
|
||||||
const cwMapped = hasCwPatch
|
const cwMapped = hasCwPatch
|
||||||
? OpportunityController.mapCwToDb(
|
? OpportunityController.mapCwToDb(
|
||||||
await opportunityCw.update(this.cwOpportunityId, cwPatch)
|
await opportunityCw.update(this.cwOpportunityId, cwPatch)
|
||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const mapped =
|
const mapped =
|
||||||
@@ -692,10 +690,10 @@ export class OpportunityController {
|
|||||||
},
|
},
|
||||||
company: contact.company
|
company: contact.company
|
||||||
? {
|
? {
|
||||||
id: contact.company.id,
|
id: contact.company.id,
|
||||||
identifier: null,
|
identifier: null,
|
||||||
name: contact.company.name,
|
name: contact.company.name,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
role: null,
|
role: null,
|
||||||
notes: null,
|
notes: null,
|
||||||
@@ -711,10 +709,10 @@ export class OpportunityController {
|
|||||||
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
|
||||||
company: ct.company
|
company: ct.company
|
||||||
? {
|
? {
|
||||||
id: ct.company.id,
|
id: ct.company.id,
|
||||||
identifier: ct.company.identifier,
|
identifier: ct.company.identifier,
|
||||||
name: ct.company.name,
|
name: ct.company.name,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
|
||||||
notes: ct.notes,
|
notes: ct.notes,
|
||||||
@@ -827,6 +825,8 @@ export class OpportunityController {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("[ROWS_DEBUG]", rows)
|
||||||
|
|
||||||
let ordered = rows;
|
let ordered = rows;
|
||||||
if (this.productSequence.length > 0) {
|
if (this.productSequence.length > 0) {
|
||||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||||
@@ -1056,7 +1056,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
const quoteNarrativeField = options.includeQuoteNarrative
|
const quoteNarrativeField = options.includeQuoteNarrative
|
||||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||||
undefined
|
undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Fall back to the customerDescription of a QUO-Narrative product
|
// Fall back to the customerDescription of a QUO-Narrative product
|
||||||
@@ -1068,8 +1068,6 @@ export class OpportunityController {
|
|||||||
quoNarrativeProduct?.customerDescription ??
|
quoNarrativeProduct?.customerDescription ??
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
|
||||||
|
|
||||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||||
|
|
||||||
// Only show attention if it differs from the customer name
|
// Only show attention if it differs from the customer name
|
||||||
@@ -1264,11 +1262,11 @@ export class OpportunityController {
|
|||||||
companyName: this.companyName ?? company?.name ?? null,
|
companyName: this.companyName ?? company?.name ?? null,
|
||||||
primaryContact: companyJson?.cw_Data?.primaryContact
|
primaryContact: companyJson?.cw_Data?.primaryContact
|
||||||
? {
|
? {
|
||||||
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
|
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
|
||||||
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
|
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
|
||||||
email: companyJson.cw_Data.primaryContact.email ?? null,
|
email: companyJson.cw_Data.primaryContact.email ?? null,
|
||||||
phone: companyJson.cw_Data.primaryContact.phone ?? null,
|
phone: companyJson.cw_Data.primaryContact.phone ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
siteAddress: siteAddress.length > 0 ? siteAddress : null,
|
siteAddress: siteAddress.length > 0 ? siteAddress : null,
|
||||||
companyAddress: companyAddress.length > 0 ? companyAddress : null,
|
companyAddress: companyAddress.length > 0 ? companyAddress : null,
|
||||||
@@ -1820,13 +1818,13 @@ export class OpportunityController {
|
|||||||
const defaultAddr = this._company?.getDefaultAddress();
|
const defaultAddr = this._company?.getDefaultAddress();
|
||||||
const companyAddress = defaultAddr
|
const companyAddress = defaultAddr
|
||||||
? {
|
? {
|
||||||
line1: defaultAddr.addressLine1,
|
line1: defaultAddr.addressLine1,
|
||||||
line2: defaultAddr.addressLine2,
|
line2: defaultAddr.addressLine2,
|
||||||
city: defaultAddr.city,
|
city: defaultAddr.city,
|
||||||
state: defaultAddr.state,
|
state: defaultAddr.state,
|
||||||
zip: defaultAddr.zipCode,
|
zip: defaultAddr.zipCode,
|
||||||
country: defaultAddr.country ?? null,
|
country: defaultAddr.country ?? null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1853,52 +1851,52 @@ export class OpportunityController {
|
|||||||
primarySalesRep:
|
primarySalesRep:
|
||||||
this.primarySalesRepIdentifier || this._primarySalesRep
|
this.primarySalesRepIdentifier || this._primarySalesRep
|
||||||
? {
|
? {
|
||||||
id: this.primarySalesRepCwId,
|
id: this.primarySalesRepCwId,
|
||||||
identifier: this.primarySalesRepIdentifier,
|
identifier: this.primarySalesRepIdentifier,
|
||||||
name:
|
name:
|
||||||
this._primarySalesRep?.name ??
|
this._primarySalesRep?.name ??
|
||||||
this.primarySalesRepName ??
|
this.primarySalesRepName ??
|
||||||
this.primarySalesRepIdentifier,
|
this.primarySalesRepIdentifier,
|
||||||
...(this._primarySalesRep
|
...(this._primarySalesRep
|
||||||
? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
|
? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
secondarySalesRep:
|
secondarySalesRep:
|
||||||
this.secondarySalesRepIdentifier || this._secondarySalesRep
|
this.secondarySalesRepIdentifier || this._secondarySalesRep
|
||||||
? {
|
? {
|
||||||
id: this.secondarySalesRepCwId,
|
id: this.secondarySalesRepCwId,
|
||||||
identifier: this.secondarySalesRepIdentifier,
|
identifier: this.secondarySalesRepIdentifier,
|
||||||
name:
|
name:
|
||||||
this._secondarySalesRep?.name ??
|
this._secondarySalesRep?.name ??
|
||||||
this.secondarySalesRepName ??
|
this.secondarySalesRepName ??
|
||||||
this.secondarySalesRepIdentifier,
|
this.secondarySalesRepIdentifier,
|
||||||
...(this._secondarySalesRep
|
...(this._secondarySalesRep
|
||||||
? {
|
? {
|
||||||
user: this._secondarySalesRep.toJson({
|
user: this._secondarySalesRep.toJson({
|
||||||
safeReturn: true,
|
safeReturn: true,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
company: this._company
|
company: this._company
|
||||||
? this._company.toJson({
|
? this._company.toJson({
|
||||||
includeAllContacts: true,
|
includeAllContacts: true,
|
||||||
includeAddress: true,
|
includeAddress: true,
|
||||||
includePrimaryContact: false,
|
includePrimaryContact: false,
|
||||||
})
|
})
|
||||||
: this.companyCwId
|
: this.companyCwId
|
||||||
? { id: this.companyCwId, name: this.companyName }
|
? { id: this.companyCwId, name: this.companyName }
|
||||||
: null,
|
: null,
|
||||||
contact: this.contactCwId
|
contact: this.contactCwId
|
||||||
? { id: this.contactCwId, name: this.contactName }
|
? { id: this.contactCwId, name: this.contactName }
|
||||||
: null,
|
: null,
|
||||||
site: this._siteData
|
site: this._siteData
|
||||||
? this._siteData
|
? this._siteData
|
||||||
: this.siteCwId
|
: this.siteCwId
|
||||||
? { id: this.siteCwId, name: this.siteName }
|
? { id: this.siteCwId, name: this.siteName }
|
||||||
: null,
|
: null,
|
||||||
customerPO: this.customerPO,
|
customerPO: this.customerPO,
|
||||||
totalSalesTax: this.totalSalesTax,
|
totalSalesTax: this.totalSalesTax,
|
||||||
expectedSalesTaxRate:
|
expectedSalesTaxRate:
|
||||||
|
|||||||
@@ -12,20 +12,20 @@
|
|||||||
import { connectWiseApi } from "../../../constants";
|
import { connectWiseApi } from "../../../constants";
|
||||||
|
|
||||||
interface CWProbability {
|
interface CWProbability {
|
||||||
id: number;
|
id: number;
|
||||||
probability: number;
|
probability: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _cache: CWProbability[] | null = null;
|
let _cache: CWProbability[] | null = null;
|
||||||
|
|
||||||
async function fetchProbabilities(): Promise<CWProbability[]> {
|
async function fetchProbabilities(): Promise<CWProbability[]> {
|
||||||
if (_cache) return _cache;
|
if (_cache) return _cache;
|
||||||
const response = await connectWiseApi.get<CWProbability[]>(
|
const response = await connectWiseApi.get<CWProbability[]>(
|
||||||
"/sales/probabilities",
|
"/sales/probabilities",
|
||||||
{ params: { pageSize: 1000 } }
|
{ params: { pageSize: 1000 } }
|
||||||
);
|
);
|
||||||
_cache = response.data;
|
_cache = response.data;
|
||||||
return _cache;
|
return _cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,29 +35,29 @@ async function fetchProbabilities(): Promise<CWProbability[]> {
|
|||||||
* Returns null if the probabilities list is empty.
|
* Returns null if the probabilities list is empty.
|
||||||
*/
|
*/
|
||||||
export async function resolveCwProbabilityId(
|
export async function resolveCwProbabilityId(
|
||||||
percent: number
|
percent: number
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const list = await fetchProbabilities();
|
const list = await fetchProbabilities();
|
||||||
if (list.length === 0) return null;
|
if (list.length === 0) return null;
|
||||||
|
|
||||||
// Exact match
|
// Exact match
|
||||||
const exact = list.find((p) => p.probability === percent);
|
const exact = list.find((p) => p.probability === percent);
|
||||||
if (exact) return exact.id;
|
if (exact) return exact.id;
|
||||||
|
|
||||||
// Closest match
|
// Closest match
|
||||||
let closest = list[0]!;
|
let closest = list[0]!;
|
||||||
let minDiff = Math.abs(closest.probability - percent);
|
let minDiff = Math.abs(closest.probability - percent);
|
||||||
for (const option of list) {
|
for (const option of list) {
|
||||||
const diff = Math.abs(option.probability - percent);
|
const diff = Math.abs(option.probability - percent);
|
||||||
if (diff < minDiff) {
|
if (diff < minDiff) {
|
||||||
minDiff = diff;
|
minDiff = diff;
|
||||||
closest = option;
|
closest = option;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return closest.id;
|
||||||
return closest.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the cache (useful for tests or forced refresh). */
|
/** Clear the cache (useful for tests or forced refresh). */
|
||||||
export function clearCwProbabilityCache(): void {
|
export function clearCwProbabilityCache(): void {
|
||||||
_cache = null;
|
_cache = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
export let onHand: number | undefined;
|
export let onHand: number | undefined;
|
||||||
|
|
||||||
// Warehouse IDs for the fixed locations
|
// Warehouse IDs for the fixed locations
|
||||||
const MURRAY_ID = 1;
|
// Murray = Andrus 301 (1) + Andrus-205C (26) + Andrus 205D (27)
|
||||||
|
const MURRAY_IDS = new Set([1, 26, 27]);
|
||||||
const UNION_CITY_ID = 6;
|
const UNION_CITY_ID = 6;
|
||||||
const LONDON_ID = 30;
|
const LONDON_ID = 30;
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const wId = row.warehouseId;
|
const wId = row.warehouseId;
|
||||||
const wName = row.warehouse?.name ?? "";
|
const wName = row.warehouse?.name ?? "";
|
||||||
if (wId === MURRAY_ID) {
|
if (wId != null && MURRAY_IDS.has(wId)) {
|
||||||
result.murray += row.qtyOnHand;
|
result.murray += row.qtyOnHand;
|
||||||
} else if (wId === UNION_CITY_ID) {
|
} else if (wId === UNION_CITY_ID) {
|
||||||
result.unionCity += row.qtyOnHand;
|
result.unionCity += row.qtyOnHand;
|
||||||
|
|||||||
@@ -3,64 +3,15 @@
|
|||||||
export let size: number = 160;
|
export let size: number = 160;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="monkey" style="width: {size}px">
|
<div class="empty-state">
|
||||||
<svg viewBox="0 0 120 120" width="100%" height="100%" aria-hidden="true">
|
<p class="msg">{message}</p>
|
||||||
<!-- head -->
|
|
||||||
<circle cx="60" cy="60" r="44" fill="#8B5E3C" />
|
|
||||||
<!-- face -->
|
|
||||||
<ellipse cx="60" cy="70" rx="30" ry="24" fill="#E8C9A1" />
|
|
||||||
<!-- ears -->
|
|
||||||
<circle cx="26" cy="56" r="12" fill="#8B5E3C" />
|
|
||||||
<circle cx="94" cy="56" r="12" fill="#8B5E3C" />
|
|
||||||
<circle cx="26" cy="56" r="6" fill="#E8C9A1" />
|
|
||||||
<circle cx="94" cy="56" r="6" fill="#E8C9A1" />
|
|
||||||
<!-- eyes -->
|
|
||||||
<circle cx="48" cy="64" r="6" fill="#2b2b2b" />
|
|
||||||
<circle cx="72" cy="64" r="6" fill="#2b2b2b" />
|
|
||||||
<!-- smile -->
|
|
||||||
<path
|
|
||||||
d="M48 78 Q60 88 72 78"
|
|
||||||
stroke="#2b2b2b"
|
|
||||||
stroke-width="3"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<!-- eyebrow accents -->
|
|
||||||
<path
|
|
||||||
d="M42 58 Q48 54 54 58"
|
|
||||||
stroke="#6b4a33"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M66 58 Q72 54 78 58"
|
|
||||||
stroke="#6b4a33"
|
|
||||||
stroke-width="2"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
<!-- tiny tuft -->
|
|
||||||
<path
|
|
||||||
d="M60 28 Q58 34 62 36"
|
|
||||||
stroke="#6b4a33"
|
|
||||||
stroke-width="3"
|
|
||||||
fill="none"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div class="msg">{message}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.monkey {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 1.5rem auto;
|
margin: 1.5rem auto;
|
||||||
}
|
}
|
||||||
.msg {
|
.msg {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/svelte";
|
|
||||||
import NoResultsMonkey from "./NoResultsMonkey.svelte";
|
|
||||||
|
|
||||||
describe("NoResultsMonkey", () => {
|
|
||||||
it("renders with default message", () => {
|
|
||||||
render(NoResultsMonkey);
|
|
||||||
|
|
||||||
expect(screen.getByText("No results found")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders with custom message", () => {
|
|
||||||
render(NoResultsMonkey, { props: { message: "Nothing here" } });
|
|
||||||
|
|
||||||
expect(screen.getByText("Nothing here")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders SVG illustration", () => {
|
|
||||||
const { container } = render(NoResultsMonkey);
|
|
||||||
|
|
||||||
const svg = container.querySelector("svg");
|
|
||||||
expect(svg).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies custom size", () => {
|
|
||||||
const { container } = render(NoResultsMonkey, {
|
|
||||||
props: { size: 200 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapper = container.querySelector(".monkey");
|
|
||||||
expect(wrapper).toHaveStyle("width: 200px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -86,9 +86,9 @@ export interface SalesOpportunity {
|
|||||||
closedFlag?: boolean;
|
closedFlag?: boolean;
|
||||||
probability?: { id?: number; percent?: number } | null;
|
probability?: { id?: number; percent?: number } | null;
|
||||||
closedBy?:
|
closedBy?:
|
||||||
| string
|
| string
|
||||||
| { id?: number | string; identifier?: string; name?: string }
|
| { id?: number | string; identifier?: string; name?: string }
|
||||||
| null;
|
| null;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
productSequence?: number[] | null;
|
productSequence?: number[] | null;
|
||||||
cwLastUpdated?: string | null;
|
cwLastUpdated?: string | null;
|
||||||
@@ -654,6 +654,9 @@ export const sales = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async fetchProducts(accessToken: string, identifier: string) {
|
async fetchProducts(accessToken: string, identifier: string) {
|
||||||
|
|
||||||
|
console.log("fetch prod exec check")
|
||||||
|
|
||||||
const response = await api.get(
|
const response = await api.get(
|
||||||
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
`/v1/sales/opportunities/opportunity/${encodeURIComponent(
|
||||||
identifier
|
identifier
|
||||||
@@ -664,6 +667,9 @@ export const sales = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("[fetchProducts] API response:", JSON.stringify(response.data, null, 2));
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../../components/NoResultsMonkey.svelte";
|
||||||
import AccessDenied from "../../../components/AccessDenied.svelte";
|
import AccessDenied from "../../../components/AccessDenied.svelte";
|
||||||
import Pagination from "../../../components/Pagination.svelte";
|
import Pagination from "../../../components/Pagination.svelte";
|
||||||
import InventoryPopover from "../../../components/InventoryPopover.svelte";
|
|
||||||
import { formatDate } from "$lib/utils";
|
import { formatDate } from "$lib/utils";
|
||||||
import "../../../styles/procurement/catalog.css";
|
import "../../../styles/procurement/catalog.css";
|
||||||
import { clientFetch } from "$lib/client-fetch";
|
import { clientFetch } from "$lib/client-fetch";
|
||||||
@@ -599,7 +598,16 @@
|
|||||||
<td class="col-price">{formatCurrency(item.price)}</td>
|
<td class="col-price">{formatCurrency(item.price)}</td>
|
||||||
<td class="col-cost">{formatCurrency(item.cost)}</td>
|
<td class="col-cost">{formatCurrency(item.cost)}</td>
|
||||||
<td class="col-onhand">
|
<td class="col-onhand">
|
||||||
<InventoryPopover identifier={item.id} onHand={item.onHand} />
|
<span
|
||||||
|
class="onhand-badge"
|
||||||
|
class:onhand-zero={item.onHand === 0}
|
||||||
|
class:onhand-low={item.onHand != null &&
|
||||||
|
item.onHand > 0 &&
|
||||||
|
item.onHand <= 3}
|
||||||
|
class:onhand-ok={item.onHand != null && item.onHand > 3}
|
||||||
|
>
|
||||||
|
{item.onHand ?? "—"}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-status">
|
<td class="col-status">
|
||||||
<span
|
<span
|
||||||
@@ -713,7 +721,17 @@
|
|||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<span class="detail-label">On Hand</span>
|
<span class="detail-label">On Hand</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
<InventoryPopover identifier={selectedItem.id} onHand={selectedItem.onHand} />
|
<span
|
||||||
|
class="onhand-badge"
|
||||||
|
class:onhand-zero={selectedItem.onHand === 0}
|
||||||
|
class:onhand-low={selectedItem.onHand != null &&
|
||||||
|
selectedItem.onHand > 0 &&
|
||||||
|
selectedItem.onHand <= 3}
|
||||||
|
class:onhand-ok={selectedItem.onHand != null &&
|
||||||
|
selectedItem.onHand > 3}
|
||||||
|
>
|
||||||
|
{selectedItem.onHand ?? "—"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -948,7 +966,18 @@
|
|||||||
<div class="linked-detail-field">
|
<div class="linked-detail-field">
|
||||||
<span class="detail-label">On Hand</span>
|
<span class="detail-label">On Hand</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
<InventoryPopover identifier={li.id} onHand={li.onHand} />
|
<span
|
||||||
|
class="onhand-badge"
|
||||||
|
class:onhand-zero={li.onHand === 0}
|
||||||
|
class:onhand-low={li.onHand != null &&
|
||||||
|
li.onHand > 0 &&
|
||||||
|
li.onHand <= 3}
|
||||||
|
class:onhand-ok={li.onHand != null &&
|
||||||
|
li.onHand > 3}
|
||||||
|
>
|
||||||
|
{li.onHand ?? "—"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="linked-detail-field">
|
<div class="linked-detail-field">
|
||||||
<span class="detail-label">Status</span>
|
<span class="detail-label">Status</span>
|
||||||
@@ -1303,7 +1332,17 @@
|
|||||||
<div class="link-preview-field">
|
<div class="link-preview-field">
|
||||||
<span class="detail-label">On Hand</span>
|
<span class="detail-label">On Hand</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
<InventoryPopover identifier={linkPreviewItem.id} onHand={linkPreviewItem.onHand} />
|
<span
|
||||||
|
class="onhand-badge"
|
||||||
|
class:onhand-zero={linkPreviewItem.onHand === 0}
|
||||||
|
class:onhand-low={linkPreviewItem.onHand != null &&
|
||||||
|
linkPreviewItem.onHand > 0 &&
|
||||||
|
linkPreviewItem.onHand <= 3}
|
||||||
|
class:onhand-ok={linkPreviewItem.onHand != null &&
|
||||||
|
linkPreviewItem.onHand > 3}
|
||||||
|
>
|
||||||
|
{linkPreviewItem.onHand ?? "—"}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if linkPreviewItem.manufacturer}
|
{#if linkPreviewItem.manufacturer}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
JSON.stringify(result, null, 2),
|
JSON.stringify(result, null, 2),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("[page.server] Full API response:", JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
const opportunity = result?.data ?? null;
|
const opportunity = result?.data ?? null;
|
||||||
const notes = result?.data?.notes ?? [];
|
const notes = result?.data?.notes ?? [];
|
||||||
const contacts = result?.data?.contacts ?? [];
|
const contacts = result?.data?.contacts ?? [];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
$: notes = data.notes;
|
$: notes = data.notes;
|
||||||
$: contacts = data.contacts;
|
$: contacts = data.contacts;
|
||||||
$: products = data.products;
|
$: products = data.products;
|
||||||
|
$: console.log("[OpportunityPage] full API response data:", data);
|
||||||
$: quotes = data.quotes ?? [];
|
$: quotes = data.quotes ?? [];
|
||||||
$: permissions = data.permissions;
|
$: permissions = data.permissions;
|
||||||
|
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { OpportunityProduct } from "../types";
|
|
||||||
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
|
||||||
|
|
||||||
export let products: OpportunityProduct[] = [];
|
|
||||||
|
|
||||||
$: discountedProducts = products.filter(
|
|
||||||
(p) => (p.discount ?? 0) !== 0 && !p.cancelled
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalDiscount = discountedProducts.reduce(
|
|
||||||
(sum, p) => sum + (p.discount ?? 0) * (p.quantity ?? 1),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalListPrice = discountedProducts.reduce(
|
|
||||||
(sum, p) => sum + (p.listPrice ?? 0) * (p.quantity ?? 1),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
$: totalUnitPrice = discountedProducts.reduce(
|
|
||||||
(sum, p) => sum + (p.unitPrice ?? 0) * (p.quantity ?? 1),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
function fmtMoney(value: number): string {
|
|
||||||
return value.toLocaleString("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtPercent(list: number, discount: number): string {
|
|
||||||
if (list === 0) return "—";
|
|
||||||
const pct = (discount / list) * 100;
|
|
||||||
return `${pct.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="discounts-tab">
|
|
||||||
<!-- Summary cards -->
|
|
||||||
{#if discountedProducts.length > 0}
|
|
||||||
<div class="discounts-summary">
|
|
||||||
<div class="discount-summary-card">
|
|
||||||
<span class="discount-summary-label">Total List Price</span>
|
|
||||||
<span class="discount-summary-value">{fmtMoney(totalListPrice)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="discount-summary-card">
|
|
||||||
<span class="discount-summary-label">Total Discounts</span>
|
|
||||||
<span class="discount-summary-value discount-negative"
|
|
||||||
>{fmtMoney(totalDiscount)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="discount-summary-card">
|
|
||||||
<span class="discount-summary-label">Net Price</span>
|
|
||||||
<span class="discount-summary-value">{fmtMoney(totalUnitPrice)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Discounts table -->
|
|
||||||
{#if discountedProducts.length === 0}
|
|
||||||
<div class="tab-empty">
|
|
||||||
<NoResultsMonkey message="No discounts applied" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="discounts-table-wrap">
|
|
||||||
<table class="discounts-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-product">Product</th>
|
|
||||||
<th class="col-num">Qty</th>
|
|
||||||
<th class="col-num">List Price</th>
|
|
||||||
<th class="col-num">Discount</th>
|
|
||||||
<th class="col-num">% Off</th>
|
|
||||||
<th class="col-num">Unit Price</th>
|
|
||||||
<th class="col-num">Ext. Discount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each discountedProducts as product (product.id)}
|
|
||||||
<tr>
|
|
||||||
<td class="col-product">
|
|
||||||
<div class="discount-product-name">
|
|
||||||
{product.forecastDescription || product.productDescription || "—"}
|
|
||||||
</div>
|
|
||||||
{#if product.catalogItem?.identifier}
|
|
||||||
<div class="discount-product-sku">
|
|
||||||
{product.catalogItem.identifier}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="col-num">{product.quantity ?? 0}</td>
|
|
||||||
<td class="col-num">{fmtMoney(product.listPrice ?? 0)}</td>
|
|
||||||
<td class="col-num discount-negative">
|
|
||||||
{fmtMoney(product.discount ?? 0)}
|
|
||||||
</td>
|
|
||||||
<td class="col-num">
|
|
||||||
{fmtPercent(product.listPrice ?? 0, product.discount ?? 0)}
|
|
||||||
</td>
|
|
||||||
<td class="col-num">{fmtMoney(product.unitPrice ?? 0)}</td>
|
|
||||||
<td class="col-num discount-negative">
|
|
||||||
{fmtMoney((product.discount ?? 0) * (product.quantity ?? 1))}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td class="col-product"><strong>Total</strong></td>
|
|
||||||
<td class="col-num"></td>
|
|
||||||
<td class="col-num"><strong>{fmtMoney(totalListPrice)}</strong></td>
|
|
||||||
<td class="col-num discount-negative">
|
|
||||||
<strong>{fmtMoney(totalDiscount)}</strong>
|
|
||||||
</td>
|
|
||||||
<td class="col-num">
|
|
||||||
{fmtPercent(totalListPrice, totalDiscount)}
|
|
||||||
</td>
|
|
||||||
<td class="col-num"><strong>{fmtMoney(totalUnitPrice)}</strong></td>
|
|
||||||
<td class="col-num discount-negative">
|
|
||||||
<strong>{fmtMoney(totalDiscount)}</strong>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.discounts-tab {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-summary {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-summary-card {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-raised, #f7f8fa);
|
|
||||||
border: 1px solid var(--border-subtle, #e2e5ea);
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-summary-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted, #6b7280);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-summary-value {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1a2e);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-negative {
|
|
||||||
color: var(--color-danger, #dc2626);
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
color: var(--text-muted, #6b7280);
|
|
||||||
border-bottom: 2px solid var(--border-subtle, #e2e5ea);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle, #e2e5ea);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table tbody tr:hover {
|
|
||||||
background: var(--bg-hover, rgba(0, 0, 0, 0.02));
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table tfoot td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-top: 2px solid var(--border-subtle, #e2e5ea);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-product {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-num {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discounts-table th.col-num {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-product-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary, #1a1a2e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-product-sku {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted, #6b7280);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.discounts-summary {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
import NoResultsMonkey from "../../../../../components/NoResultsMonkey.svelte";
|
||||||
import AddProductModal from "../../../../../components/AddProductModal.svelte";
|
import AddProductModal from "../../../../../components/AddProductModal.svelte";
|
||||||
import InventoryPopover from "../../../../../components/InventoryPopover.svelte";
|
|
||||||
import type { CatalogItem } from "$lib/optima-api/modules/procurement";
|
import type { CatalogItem } from "$lib/optima-api/modules/procurement";
|
||||||
import type {
|
import type {
|
||||||
AddProductBody,
|
AddProductBody,
|
||||||
@@ -2557,12 +2556,7 @@
|
|||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<span class="detail-field-label">On Hand</span>
|
<span class="detail-field-label">On Hand</span>
|
||||||
<span class="detail-field-value">
|
<span class="detail-field-value">
|
||||||
{#if selectedProduct.catalogItem}
|
{#if selectedProduct.onHand != null}
|
||||||
<InventoryPopover
|
|
||||||
identifier={String(selectedProduct.catalogItem.id)}
|
|
||||||
onHand={selectedProduct.onHand ?? undefined}
|
|
||||||
/>
|
|
||||||
{:else if selectedProduct.onHand != null}
|
|
||||||
<span
|
<span
|
||||||
class="stock-badge"
|
class="stock-badge"
|
||||||
class:stock-none={selectedProduct.onHand === 0}
|
class:stock-none={selectedProduct.onHand === 0}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Reference in New Issue
Block a user