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:
2026-04-22 00:53:23 +00:00
parent 5194d0e21e
commit 6eee7bf0da
19 changed files with 406 additions and 507 deletions
-2
View File
@@ -20,8 +20,6 @@ export default createRoute(
const includeAllContacts = c.req.query("includeAllContacts") === "true";
const includeAllAddresses = c.req.query("includeAllAddresses") === "true";
console.log(company.toJson({ includeAddress, includePrimaryContact, includeAllContacts }));
// Check for address-specific permission if includeAddress is requested
if (includeAddress) {
const user = c.get("user");
-2
View File
@@ -35,8 +35,6 @@ export default createRoute(
const data = schema.parse(body);
console.log("Creating Credential Type with data:", data);
const credentialType = await credentialTypes.create(data as any);
const response = apiResponse.created(
+4 -1
View File
@@ -36,7 +36,10 @@ export default createRoute(
if (includes.has("products")) {
subResourcePromises.products = item
.fetchProducts()
.then((products) => products.map((p) => p.toJson()));
.then((products) => {
const json = products.map((p) => p.toJson());
return json;
});
}
if (includes.has("quotes")) {
subResourcePromises.quotes = generatedQuotes
@@ -39,7 +39,11 @@ export default createRoute(
if (includes.has("products")) {
subResourcePromises.products = item
.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")) {
const includeRegenData = c.req.query("includeRegenData") === "true";
@@ -116,15 +116,7 @@ export default createRoute(
try {
const identifier = c.req.param("identifier");
const body = await c.req.json();
console.log(
"[Workflow Dispatch] Raw request body:",
JSON.stringify(body, null, 2),
);
const parsed = dispatchSchema.parse(body);
console.log(
"[Workflow Dispatch] Parsed payload:",
JSON.stringify(parsed.payload, null, 2),
);
const user = c.get("user");
// ── Resolve opportunity ────────────────────────────────────────────
@@ -182,9 +182,9 @@ export default createRoute(
const memberRecords = allMemberIds.length
? await prisma.cwMember.findMany({
where: { identifier: { in: allMemberIds } },
select: { identifier: true, firstName: true, lastName: true, officeEmail: true },
})
where: { identifier: { in: allMemberIds } },
select: { identifier: true, firstName: true, lastName: true, officeEmail: true },
})
: [];
const memberMap = new Map(
+66 -68
View File
@@ -37,11 +37,11 @@ async function resolveMember(identifier: string | null | undefined) {
});
return member
? {
id: member.id,
identifier: member.identifier,
name: `${member.firstName} ${member.lastName}`.trim(),
cwMemberId: member.cwMemberId,
}
id: member.id,
identifier: member.identifier,
name: `${member.firstName} ${member.lastName}`.trim(),
cwMemberId: member.cwMemberId,
}
: { id: null, identifier, name: identifier, cwMemberId: null };
}
@@ -193,8 +193,8 @@ export class OpportunityController {
});
const resolvedName = user
? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() ||
user.login ||
user.email
user.login ||
user.email
: null;
const name =
resolvedName ??
@@ -209,8 +209,8 @@ export class OpportunityController {
constructor(
data: Opportunity & {
company?:
| (Company & { contacts?: any[]; companyAddresses?: any[] })
| null;
| (Company & { contacts?: any[]; companyAddresses?: any[] })
| null;
primarySalesRep?: (User & { roles: Role[] }) | null;
secondarySalesRep?: (User & { roles: Role[] }) | null;
},
@@ -222,8 +222,6 @@ export class OpportunityController {
activities?: ActivityController[];
}
) {
console.log(data.primarySalesRep);
// New schema: uid is the internal PK (string), id is the CW opportunity ID (Int)
this.id = data.uid;
this.cwOpportunityId = data.id;
@@ -515,8 +513,8 @@ export class OpportunityController {
const hasCwPatch = Object.keys(cwPatch).length > 0;
const cwMapped = hasCwPatch
? OpportunityController.mapCwToDb(
await opportunityCw.update(this.cwOpportunityId, cwPatch)
)
await opportunityCw.update(this.cwOpportunityId, cwPatch)
)
: {};
const mapped =
@@ -692,10 +690,10 @@ export class OpportunityController {
},
company: contact.company
? {
id: contact.company.id,
identifier: null,
name: contact.company.name,
}
id: contact.company.id,
identifier: null,
name: contact.company.name,
}
: null,
role: null,
notes: null,
@@ -711,10 +709,10 @@ export class OpportunityController {
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
company: ct.company
? {
id: ct.company.id,
identifier: ct.company.identifier,
name: ct.company.name,
}
id: ct.company.id,
identifier: ct.company.identifier,
name: ct.company.name,
}
: null,
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
notes: ct.notes,
@@ -827,6 +825,8 @@ export class OpportunityController {
},
});
console.log("[ROWS_DEBUG]", rows)
let ordered = rows;
if (this.productSequence.length > 0) {
const byId = new Map(rows.map((row) => [row.id, row]));
@@ -1056,7 +1056,7 @@ export class OpportunityController {
const quoteNarrativeField = options.includeQuoteNarrative
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
undefined
undefined
: undefined;
// Fall back to the customerDescription of a QUO-Narrative product
@@ -1068,8 +1068,6 @@ export class OpportunityController {
quoNarrativeProduct?.customerDescription ??
undefined;
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
// Only show attention if it differs from the customer name
@@ -1264,11 +1262,11 @@ export class OpportunityController {
companyName: this.companyName ?? company?.name ?? null,
primaryContact: companyJson?.cw_Data?.primaryContact
? {
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
email: companyJson.cw_Data.primaryContact.email ?? null,
phone: companyJson.cw_Data.primaryContact.phone ?? null,
}
: null,
siteAddress: siteAddress.length > 0 ? siteAddress : null,
companyAddress: companyAddress.length > 0 ? companyAddress : null,
@@ -1820,13 +1818,13 @@ export class OpportunityController {
const defaultAddr = this._company?.getDefaultAddress();
const companyAddress = defaultAddr
? {
line1: defaultAddr.addressLine1,
line2: defaultAddr.addressLine2,
city: defaultAddr.city,
state: defaultAddr.state,
zip: defaultAddr.zipCode,
country: defaultAddr.country ?? null,
}
line1: defaultAddr.addressLine1,
line2: defaultAddr.addressLine2,
city: defaultAddr.city,
state: defaultAddr.state,
zip: defaultAddr.zipCode,
country: defaultAddr.country ?? null,
}
: null;
return {
@@ -1853,52 +1851,52 @@ export class OpportunityController {
primarySalesRep:
this.primarySalesRepIdentifier || this._primarySalesRep
? {
id: this.primarySalesRepCwId,
identifier: this.primarySalesRepIdentifier,
name:
this._primarySalesRep?.name ??
this.primarySalesRepName ??
this.primarySalesRepIdentifier,
...(this._primarySalesRep
? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
: {}),
}
id: this.primarySalesRepCwId,
identifier: this.primarySalesRepIdentifier,
name:
this._primarySalesRep?.name ??
this.primarySalesRepName ??
this.primarySalesRepIdentifier,
...(this._primarySalesRep
? { user: this._primarySalesRep.toJson({ safeReturn: true }) }
: {}),
}
: null,
secondarySalesRep:
this.secondarySalesRepIdentifier || this._secondarySalesRep
? {
id: this.secondarySalesRepCwId,
identifier: this.secondarySalesRepIdentifier,
name:
this._secondarySalesRep?.name ??
this.secondarySalesRepName ??
this.secondarySalesRepIdentifier,
...(this._secondarySalesRep
? {
user: this._secondarySalesRep.toJson({
safeReturn: true,
}),
}
: {}),
}
id: this.secondarySalesRepCwId,
identifier: this.secondarySalesRepIdentifier,
name:
this._secondarySalesRep?.name ??
this.secondarySalesRepName ??
this.secondarySalesRepIdentifier,
...(this._secondarySalesRep
? {
user: this._secondarySalesRep.toJson({
safeReturn: true,
}),
}
: {}),
}
: null,
company: this._company
? this._company.toJson({
includeAllContacts: true,
includeAddress: true,
includePrimaryContact: false,
})
includeAllContacts: true,
includeAddress: true,
includePrimaryContact: false,
})
: this.companyCwId
? { id: this.companyCwId, name: this.companyName }
: null,
? { id: this.companyCwId, name: this.companyName }
: null,
contact: this.contactCwId
? { id: this.contactCwId, name: this.contactName }
: null,
site: this._siteData
? this._siteData
: this.siteCwId
? { id: this.siteCwId, name: this.siteName }
: null,
? { id: this.siteCwId, name: this.siteName }
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
expectedSalesTaxRate:
@@ -12,20 +12,20 @@
import { connectWiseApi } from "../../../constants";
interface CWProbability {
id: number;
probability: number;
id: number;
probability: number;
}
let _cache: CWProbability[] | null = null;
async function fetchProbabilities(): Promise<CWProbability[]> {
if (_cache) return _cache;
const response = await connectWiseApi.get<CWProbability[]>(
"/sales/probabilities",
{ params: { pageSize: 1000 } }
);
_cache = response.data;
return _cache;
if (_cache) return _cache;
const response = await connectWiseApi.get<CWProbability[]>(
"/sales/probabilities",
{ params: { pageSize: 1000 } }
);
_cache = response.data;
return _cache;
}
/**
@@ -35,29 +35,29 @@ async function fetchProbabilities(): Promise<CWProbability[]> {
* Returns null if the probabilities list is empty.
*/
export async function resolveCwProbabilityId(
percent: number
percent: number
): Promise<number | null> {
const list = await fetchProbabilities();
if (list.length === 0) return null;
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;
// 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;
// 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;
return closest.id;
}
/** Clear the cache (useful for tests or forced refresh). */
export function clearCwProbabilityCache(): void {
_cache = null;
_cache = null;
}