diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte
index 7650129..75bf26e 100644
--- a/src/components/AddProductModal.svelte
+++ b/src/components/AddProductModal.svelte
@@ -10,7 +10,7 @@
export let isOpen = false;
export let accessToken: string;
- export let onSelect: (item: CatalogItem) => void = () => {};
+ export let onSelect: (items: CatalogItem | CatalogItem[]) => void = () => {};
// ── Step state ──
// "browse" → pick category, ecosystem, or search
@@ -295,8 +295,9 @@
}
function handleAddSelected() {
- // Placeholder — TODO: implement bulk add
- console.log("Add selected items:", cart);
+ if (cart.length === 0) return;
+ onSelect([...cart]);
+ handleClose();
}
// ── Back / Home ──
@@ -430,6 +431,30 @@
}
}
+ // ── Quick-add special items ──
+
+ function addSpecialOrder() {
+ const specialItem: CatalogItem = {
+ id: `special-order-${Date.now()}`,
+ identifier: "SPECIAL-ORDER",
+ description: "Special Order",
+ category: "Special Order",
+ };
+ onSelect(specialItem);
+ handleClose();
+ }
+
+ function addLabor() {
+ const laborItem: CatalogItem = {
+ id: `labor-${Date.now()}`,
+ identifier: "LABOR",
+ description: "Labor",
+ category: "Labor",
+ };
+ onSelect(laborItem);
+ handleClose();
+ }
+
// ── Helpers ──
function formatPrice(amount?: number | null): string {
@@ -1012,6 +1037,62 @@
+
+
Quick Actions
+
+
+
+
+
{#if categories.length > 0}
Categories
@@ -1867,7 +1948,7 @@
/* ── Modal shell wrapper ── */
.modal-shell {
position: relative;
- height: 88vh;
+ height: 80vh;
width: 94%;
max-width: 720px;
animation: modalIn 0.15s ease;
@@ -2309,6 +2390,74 @@
flex-shrink: 0;
}
+ /* ── Quick Actions ── */
+ .quick-actions {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 18px;
+ }
+
+ .quick-action-btn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex: 1;
+ padding: 12px 14px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ transition:
+ background 0.12s,
+ border-color 0.12s,
+ box-shadow 0.12s;
+ }
+
+ .quick-action-btn:hover {
+ background: var(--card-hover-bg);
+ border-color: var(--border-default, var(--border-subtle));
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+ }
+
+ .quick-action-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ flex-shrink: 0;
+ }
+
+ .quick-action-icon.special-order-icon {
+ background: rgba(251, 191, 36, 0.1);
+ color: #f59e0b;
+ }
+
+ .quick-action-icon.labor-icon {
+ background: rgba(52, 211, 153, 0.1);
+ color: #10b981;
+ }
+
+ .quick-action-label {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .quick-action-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ }
+
+ .quick-action-desc {
+ font-size: 11px;
+ color: var(--text-muted);
+ }
+
/* ── Category detail page ── */
.category-detail-page {
display: flex;
diff --git a/src/lib/optima-api/modules/api-modules.spec.ts b/src/lib/optima-api/modules/api-modules.spec.ts
index c98760f..4154f4b 100644
--- a/src/lib/optima-api/modules/api-modules.spec.ts
+++ b/src/lib/optima-api/modules/api-modules.spec.ts
@@ -357,10 +357,9 @@ describe("optima api modules", () => {
await sales.fetchOne("token", "opp-1");
- expect(mockApi.get).toHaveBeenCalledWith(
- "/v1/sales/opportunities/opp-1",
- { headers: { Authorization: "Bearer token" } },
- );
+ expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities/opp-1", {
+ headers: { Authorization: "Bearer token" },
+ });
});
it("sales.fetchForecasts calls forecasts endpoint", async () => {
diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts
index 3ab89cb..50b57a1 100644
--- a/src/lib/optima-api/modules/sales.ts
+++ b/src/lib/optima-api/modules/sales.ts
@@ -77,6 +77,7 @@ export interface SalesOpportunity {
closedFlag?: boolean;
closedBy?: string | null;
companyId?: string;
+ productSequence?: number[] | null;
cwLastUpdated?: string | null;
createdAt?: string;
updatedAt?: string;
@@ -97,6 +98,26 @@ export interface OpportunityType {
optimaEquivalency?: number[];
}
+export interface AddProductBody {
+ catalogItem?: { id: number };
+ forecastDescription?: string;
+ productDescription?: string;
+ quantity?: number;
+ status?: { id: number };
+ productClass?: string;
+ forecastType?: string;
+ revenue?: number;
+ cost?: number;
+ includeFlag?: boolean;
+ linkFlag?: boolean;
+ recurringFlag?: boolean;
+ taxableFlag?: boolean;
+ recurringRevenue?: number;
+ recurringCost?: number;
+ cycles?: number;
+ sequenceNumber?: number;
+}
+
export const sales = {
async fetchMany(
accessToken: string,
@@ -251,6 +272,23 @@ export const sales = {
return response.data;
},
+ async addProduct(
+ accessToken: string,
+ identifier: string,
+ body: AddProductBody,
+ ) {
+ const response = await api.post(
+ `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products`,
+ body,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+ return response.data;
+ },
+
async refreshOpportunity(accessToken: string, identifier: string) {
const response = await api.post(
`/v1/sales/opportunities/${encodeURIComponent(identifier)}/refresh`,
diff --git a/src/lib/permissions.spec.ts b/src/lib/permissions.spec.ts
index 897b446..87fd5c9 100644
--- a/src/lib/permissions.spec.ts
+++ b/src/lib/permissions.spec.ts
@@ -71,10 +71,9 @@ describe("permissions helpers", () => {
});
it("returns all-true with __checkFailed when accessToken is falsy", async () => {
- const result = await checkPermissions(
- undefined as unknown as string,
- ["perm.a"],
- );
+ const result = await checkPermissions(undefined as unknown as string, [
+ "perm.a",
+ ]);
expect(result["perm.a"]).toBe(true);
expect(result.__checkFailed).toBe(true);
diff --git a/src/routes/procurement/catalog/+page.server.ts b/src/routes/procurement/catalog/+page.server.ts
index 1b84ae1..59a257d 100644
--- a/src/routes/procurement/catalog/+page.server.ts
+++ b/src/routes/procurement/catalog/+page.server.ts
@@ -23,7 +23,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
try {
const [result, permissions] = await Promise.all([
optima.procurement
- .fetchMany(accessToken, page, search, 30, includeInactive)
+ .fetchMany(accessToken, page, { search, includeInactive }, 30)
.catch((err) => {
console.error(
"Failed to fetch catalog items:",
diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte
index 36399ab..b9d3e59 100644
--- a/src/routes/sales/opportunity/[id]/+page.svelte
+++ b/src/routes/sales/opportunity/[id]/+page.svelte
@@ -216,12 +216,13 @@
{#if activeTab === "Overview"}
-
+
{:else if activeTab === "Products"}
{:else if activeTab === "Notes"}
{/if}
+
+
+
{:else}