diff --git a/README.md b/README.md
index 32f966c..4d086b4 100644
--- a/README.md
+++ b/README.md
@@ -1,116 +1,144 @@
-# SveltronKit
+# Project Optima — UI
-A minimal template for building Electron apps with SvelteKit.
+The frontend for **Project Optima**, a credential and company management platform. Ships as both a **web application** (deployed to Kubernetes) and a **cross-platform desktop app** (Electron for macOS and Windows).
-Includes native support for Typscript and uses Electron's official recommended Electron Forge for packaging.
+Built with **SvelteKit**, **TypeScript**, **Tailwind CSS**, and **Electron**.
-Everything you can do in SvelteKit, you can do in SveltronKit; meaning that you can use component
-libraries like [Shadcn-Svelte](https://next.shadcn-svelte.com/).
+## Features
-> [!IMPORTANT]
-> This template uses SvelteKit's [hash router](https://svelte.dev/docs/kit/configuration#router) to
-> create a single-page app. The only difference you'll have to look out for is to start all your routed
-> links with `#/` instead of `/`.
+- **Authentication** — OAuth-based login flow via the Optima API
+- **Company Management** — Browse and manage companies and linked Unifi sites
+- **Credential Management** — Create, view, and organize credentials by type
+- **Admin Panel** — User management, role & permission assignment, credential type configuration
+- **Dark/Light Theme** — Toggle between themes with persistent preference
+- **Desktop App** — Native macOS (.dmg) and Windows (.exe/.msi) builds via Electron Forge
+- **Web Deployment** — Containerized SvelteKit server deployed to Kubernetes
-## Dependencies & Frameworks
+## Tech Stack
-- [SvelteKit](https://kit.svelte.dev/)
-- [Electron](https://www.electronjs.org/)
-- [Electron Forge](https://www.electronforge.io/)
-- [TypeScript](https://www.typescriptlang.org/)
-- [TailwindCSS](https://tailwindcss.com/)
-
-> [!NOTE]
-> I've included TailwindCSS in this template because I use it in my own projects, but you can remove
-> it easily if you don't want it.
+| Layer | Technology |
+| --------------- | ----------------------------------------------------------------------------------------- |
+| Framework | [SvelteKit](https://kit.svelte.dev/) |
+| Language | [TypeScript](https://www.typescriptlang.org/) |
+| Styling | [Tailwind CSS v4](https://tailwindcss.com/) |
+| Desktop | [Electron](https://www.electronjs.org/) + [Electron Forge](https://www.electronforge.io/) |
+| API Client | [Axios](https://axios-http.com/) |
+| Realtime | [Socket.IO](https://socket.io/) |
+| Testing | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
+| Package Manager | [Bun](https://bun.sh/) |
## Getting Started
-> [!WARNING]
-> This project uses [`bun`](https://bun.sh/) and uses [patching](https://bun.sh/docs/install/patch) to work
-> around some issues with SvelteKit. When this [PR](https://github.com/sveltejs/kit/pull/13812) merges,
-> you can remove the patching and use the latest version of SvelteKit.
+### Prerequisites
-Start by installing the dependencies:
+- [Bun](https://bun.sh/) (package manager & runtime)
+- [Node.js 22+](https://nodejs.org/) (for Electron)
-```
+### Installation
+
+```bash
bun install
```
-**Development:**
+> **Note:** This project patches `@sveltejs/kit` via `patches/` to work around routing issues.
-```
+### Development (Desktop)
+
+```bash
bun start
```
-[Electron Forge](https://www.electronforge.io/) with the [Vite plugin](https://www.electronforge.io/plugins/vite)
-will take care of running the development server and building the app for you. You don't need to run
-`vite dev` or `vite build` yourself. This also means that it supports hot module replacement (HMR).
+Launches Electron with Vite HMR. The app opens to the login page and connects to the Optima API.
-**Production:**
+### Development (Web Server)
-```
-bun run package
+```bash
+bun run build:server
+node build/index.js
```
-This will build the app and you can find the output in the `out` directory. You can run the production
-app by opening the `.app` file in the `out` directory. This will not create your app's installer
-for distribution though.
+Builds and runs the SvelteKit server with `adapter-node` on port 3000.
-To create a distributable installer, you can use:
+## Building
-```
-bun run make
+### Desktop — macOS
+
+```bash
+bun run make:macos
```
-This will create a distributable installer for your app. You can configure this in the `makers` section
-in `forge.config.ts`. Reference the [makers documentation](https://www.electronforge.io/makers) for more
-information.
+Outputs `.dmg` and `.zip` to `out/make/`.
-# Electron Crash Course
+### Desktop — Windows
-> [!NOTE]
-> This is a super simplified version of the Electron documentation meant to give you a general idea
-> of how Electron works and how each file corresponds to responsibilities in Electron. For a more
-> accurate description of how Electron works, you can refer to the [official documentation](https://www.electronjs.org/docs).
-
-I found that most of the problems I encountered when setting up Electron were because I didn't know
-how Electron works and that the documentation was too dense to get up to speed with, so I'll include
-a crash course here. _I will be making a lot of analogies to web development_ as it seems like a lot
-of people who are new to Electron come from web development.
-
-Because everything in Electron is client based, you'll need to host your own server if you want to
-access any sensitive logic like a database or authentication, etc.
-
-## main.ts
-
-This file defines what the main process will do. The process runs your app. It's the one that
-creates and manages windows and also has permissions to access the file system. You also define
-"_signals_"/"_endpoints_", through IPC, that let the renderer process (browser that runs your app)
-can "_call_" to interact with the file system.
-
-By default, Electron will block off file system access to the renderer process as a security measure,
-which is the reason why you need to use IPC to interact with the file system.
-
-## preload.ts
-
-Think about this as a "bridge" or a "network"/"proxy" between the main process and the renderer process.
-You specify what functions that the renderer process can call and these functions will usually be
-interacting with the file system through the main process.
-
-## renderer
-
-The renderer process is the browser that runs your app. Just treat this like another SvelteKit app.
-
-## Overview
-
-```mermaid
-flowchart LR
- subgraph main[Main Process]
- electron
- end
- subgraph renderer[Renderer Process]
- browser
- end
- electron <-- preload --> renderer
+```bash
+npm run make -- --platform win32
```
+
+Outputs `.exe`, `.msi`, and `.nupkg` to `out/make/`.
+
+### Docker (Web Server)
+
+```bash
+docker build -t optima-ui .
+docker run -p 3000:3000 optima-ui
+```
+
+The Dockerfile builds the SvelteKit app, bundles it with `bun build` into a single file (no `node_modules` needed at runtime), and serves it on a minimal `node:22-alpine` image.
+
+## Project Structure
+
+```
+├── electron/ # Electron main & preload processes
+│ ├── main.ts # Window creation, IPC handlers
+│ └── preload.ts # Main ↔ Renderer bridge
+├── src/
+│ ├── components/ # Reusable Svelte components (modals, spinners, etc.)
+│ ├── lib/
+│ │ ├── optima-api/ # API client modules (auth, companies, credentials, etc.)
+│ │ ├── permissions.ts # Permission helpers
+│ │ └── theme.ts # Theme store (dark/light)
+│ ├── routes/
+│ │ ├── (auth)/ # Auth pages (login)
+│ │ ├── admin/ # Admin panel (users, roles, credential types)
+│ │ ├── companies/ # Company management
+│ │ └── +page.svelte # Home page
+│ └── styles/ # CSS (Tailwind)
+├── kubernetes/ # K8s deployment & ingress manifests
+├── .github/workflows/ # CI/CD (build, publish, deploy)
+├── Dockerfile # Multi-stage build (bun → node:alpine)
+└── forge.config.ts # Electron Forge config
+```
+
+## Testing
+
+```bash
+# Unit tests (Vitest)
+bun run test:unit
+
+# E2E tests (Playwright)
+bun run test:e2e
+
+# All tests
+bun run test
+```
+
+## Deployment
+
+Releases are automated via GitHub Actions. Creating a release triggers:
+
+1. **Docker image build** → pushed to `ghcr.io/project-optima/ttscm-ui`
+2. **Desktop builds** → macOS and Windows artifacts attached to the release
+3. **Kubernetes deploy** → rolls out the new image to the `optima` namespace
+
+## Environment Variables
+
+| Variable | Description | Default |
+| ---------------- | -------------------------------- | --------------------------- |
+| `PUBLIC_API_URL` | Optima API base URL | `https://opt-api.osdci.net` |
+| `ORIGIN` | Allowed origin for CORS (server) | `https://optima.osdci.net` |
+| `PORT` | Server listen port | `3000` |
+
+## License
+
+See [LICENSE](LICENSE) for details.
diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml
index 70fcf48..43f541d 100644
--- a/kubernetes/deployment.yaml
+++ b/kubernetes/deployment.yaml
@@ -17,6 +17,13 @@ spec:
- name: optima-ui
image: ghcr.io/project-optima/ttscm-ui:latest
imagePullPolicy: Always
+ resources:
+ requests:
+ memory: "128Mi"
+ cpu: "100m"
+ limits:
+ memory: "256Mi"
+ cpu: "500m"
env:
- name: PUBLIC_API_URL
value: "https://opt-api.osdci.net"
@@ -26,5 +33,17 @@ spec:
value: "3000"
ports:
- containerPort: 3000
+ livenessProbe:
+ httpGet:
+ path: /login
+ port: 3000
+ initialDelaySeconds: 5
+ periodSeconds: 15
+ readinessProbe:
+ httpGet:
+ path: /login
+ port: 3000
+ initialDelaySeconds: 3
+ periodSeconds: 5
imagePullSecrets:
- name: github-container-registry
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index ede8154..cfb19c3 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -1,8 +1,118 @@
// src/hooks.server.ts
import { optima } from "$lib";
+import { isInvalidSignatureError } from "$lib/optima-api/errorHandler";
import { redirect, type Handle } from "@sveltejs/kit";
+import api from "$lib/optima-api/axios";
+
+function apiUnreachablePage(): Response {
+ const html = `
+
+
+
+
+ Service Unavailable — Project Optima
+
+
+
+
+
+
Unable to Reach API
+
+ Project Optima cannot connect to the API server. This may be due to a
+ network issue or the API server being temporarily unavailable.
+
+
+
+
+
+ Retry
+
+
If this persists, contact your system administrator.
+
+
+`;
+
+ return new Response(html, {
+ status: 503,
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ });
+}
export const handle: Handle = async ({ event, resolve }) => {
+ // Health-check the API before doing anything else.
+ // /v1/teapot returns 418 when the API is alive.
+ try {
+ const health = await api.get("/v1/teapot", {
+ timeout: 5000,
+ validateStatus: () => true,
+ });
+ if (health.status !== 418) throw new Error("Unexpected status");
+ } catch {
+ return apiUnreachablePage();
+ }
+
const accessToken = event.cookies.get("accessToken") || null;
const refreshToken = event.cookies.get("refreshToken") || null;
@@ -52,7 +162,13 @@ export const handle: Handle = async ({ event, resolve }) => {
return redirect(303, "/login");
}
}
- } catch {
+ } catch (err) {
+ // Invalid signature means tokens are fundamentally bad — don't attempt refresh
+ if (isInvalidSignatureError(err)) {
+ console.warn("Invalid token signature detected — forcing logout.");
+ optima.user.logout(event);
+ return redirect(303, "/login");
+ }
// Token is malformed or refresh failed — try refresh as fallback
if (currentRefreshToken) {
try {
@@ -60,7 +176,10 @@ export const handle: Handle = async ({ event, resolve }) => {
await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
- } catch {
+ } catch (refreshErr) {
+ if (isInvalidSignatureError(refreshErr)) {
+ console.warn("Invalid refresh token signature — forcing logout.");
+ }
// Refresh also failed, force re-login
optima.user.logout(event);
return redirect(303, "/login");
@@ -76,7 +195,10 @@ export const handle: Handle = async ({ event, resolve }) => {
const refreshed = await optima.user.refreshSession(currentRefreshToken);
currentAccessToken = refreshed.accessToken;
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
- } catch {
+ } catch (err) {
+ if (isInvalidSignatureError(err)) {
+ console.warn("Invalid refresh token signature — forcing logout.");
+ }
optima.user.logout(event);
return redirect(303, "/login");
}
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 80c4710..743d68c 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -9,6 +9,8 @@ import { permission } from "./optima-api/modules/permissions";
import { user } from "./optima-api/modules/user";
import { users } from "./optima-api/modules/users";
import { unifi } from "./optima-api/modules/unifi";
+import { procurement } from "./optima-api/modules/procurement";
+import { sales } from "./optima-api/modules/sales";
export const optima = {
auth,
@@ -20,6 +22,8 @@ export const optima = {
user,
users,
unifi,
+ procurement,
+ sales,
};
/**
* @TODO
diff --git a/src/lib/optima-api/errorHandler.ts b/src/lib/optima-api/errorHandler.ts
index aeea98f..923292d 100644
--- a/src/lib/optima-api/errorHandler.ts
+++ b/src/lib/optima-api/errorHandler.ts
@@ -1,4 +1,4 @@
-import { error } from "@sveltejs/kit";
+import { error, redirect } from "@sveltejs/kit";
export class ApiError extends Error {
constructor(
@@ -11,9 +11,43 @@ export class ApiError extends Error {
}
}
+/**
+ * Detects "invalid signature" or malformed-token errors from the API,
+ * which indicate the access or refresh token has been tampered with or
+ * the server signing key has changed.
+ */
+export function isInvalidSignatureError(err: unknown): boolean {
+ if (err && typeof err === "object") {
+ const axiosErr = err as Record;
+ const responseData = (axiosErr?.response as Record)
+ ?.data as Record | undefined;
+ const candidates = [
+ responseData?.message,
+ responseData?.error,
+ (err as Error)?.message,
+ ];
+ return candidates.some((val) => {
+ if (typeof val !== "string") return false;
+ const lower = val.toLowerCase();
+ return (
+ lower.includes("invalid signature") ||
+ lower.includes("jwt malformed") ||
+ lower.includes("invalid token")
+ );
+ });
+ }
+ return false;
+}
+
export function handleApiError(err: unknown): never {
console.error("API Error:", err);
+ // Treat invalid-signature errors as a forced logout
+ if (isInvalidSignatureError(err)) {
+ console.warn("Invalid token signature detected — forcing logout.");
+ throw redirect(303, "/logout");
+ }
+
if (err instanceof ApiError) {
throw error(err.statusCode, {
message: err.message,
diff --git a/src/lib/optima-api/modules/procurement.ts b/src/lib/optima-api/modules/procurement.ts
new file mode 100644
index 0000000..d03e407
--- /dev/null
+++ b/src/lib/optima-api/modules/procurement.ts
@@ -0,0 +1,104 @@
+import api from "../axios";
+
+export const procurement = {
+ async fetchMany(
+ accessToken: string,
+ page: number = 1,
+ search?: string,
+ rpp: number = 30,
+ includeInactive: boolean = false,
+ ) {
+ const params: Record = { page, rpp };
+ if (search && search.length > 0) params.search = search;
+ if (includeInactive) params.includeInactive = true;
+
+ const response = await api.get("/v1/procurement/items", {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+ },
+
+ async fetch(
+ accessToken: string,
+ identifier: string,
+ options?: { includeLinkedItems?: boolean },
+ ) {
+ const params: Record = {};
+ if (options?.includeLinkedItems) params.includeLinkedItems = "true";
+
+ const response = await api.get(`/v1/procurement/items/${identifier}`, {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+ },
+
+ async count(accessToken: string, activeOnly: boolean = false) {
+ const params: Record = {};
+ if (activeOnly) params.activeOnly = "true";
+
+ const response = await api.get("/v1/procurement/count", {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data.data.count;
+ },
+
+ async refreshInventory(accessToken: string, identifier: string) {
+ const response = await api.post(
+ `/v1/procurement/items/${identifier}/refresh-inventory`,
+ {},
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+ return response.data;
+ },
+
+ async fetchLinkedItems(accessToken: string, identifier: string) {
+ const response = await api.get(
+ `/v1/procurement/items/${identifier}/linked`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+ return response.data;
+ },
+
+ async linkItem(accessToken: string, identifier: string, targetId: string) {
+ const response = await api.post(
+ `/v1/procurement/items/${identifier}/link`,
+ { targetId },
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+ return response.data;
+ },
+
+ async unlinkItem(accessToken: string, identifier: string, targetId: string) {
+ const response = await api.post(
+ `/v1/procurement/items/${identifier}/unlink`,
+ { targetId },
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+ return response.data;
+ },
+};
diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts
new file mode 100644
index 0000000..0626806
--- /dev/null
+++ b/src/lib/optima-api/modules/sales.ts
@@ -0,0 +1,62 @@
+import api from "../axios";
+
+export interface SalesOpportunity {
+ id: string;
+ cwOpportunityId?: number;
+ name: string;
+ notes?: string | null;
+ type?: { id?: number; name?: string } | null;
+ stage?: { id?: number; name?: string } | null;
+ status?: { id?: number; name?: string } | null;
+ priority?: { id?: number; name?: string } | null;
+ rating?: { id?: number; name?: string } | null;
+ source?: string | null;
+ campaign?: string | null;
+ primarySalesRep?: {
+ id?: number;
+ identifier?: string;
+ name?: string;
+ } | null;
+ secondarySalesRep?: {
+ id?: number;
+ identifier?: string;
+ name?: string;
+ } | null;
+ company?: { id?: number | string; name?: string } | null;
+ contact?: { id?: number | string; name?: string } | null;
+ site?: { id?: number | string; name?: string } | null;
+ customerPO?: string | null;
+ totalSalesTax?: number | null;
+ expectedCloseDate?: string | null;
+ pipelineChangeDate?: string | null;
+ dateBecameLead?: string | null;
+ closedDate?: string | null;
+ closedFlag?: boolean;
+ closedBy?: string | null;
+ companyId?: string;
+ cwLastUpdated?: string | null;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export const sales = {
+ async fetchMany(
+ accessToken: string,
+ page: number = 1,
+ search: string = "",
+ rpp: number = 30,
+ includeClosed: boolean = true,
+ ) {
+ const params: Record = { page, rpp };
+ if (search) params.search = search;
+ if (includeClosed) params.includeClosed = true;
+
+ const response = await api.get("/v1/sales/opportunities", {
+ params,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+ return response.data;
+ },
+};
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index 77ef793..58a8288 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -11,6 +11,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
let canViewAdmin = false;
try {
+ const userInfo = await optima.user.fetchInfo(accessToken);
+ console.log("@me response:", JSON.stringify(userInfo, null, 2));
+
const permResult = await optima.user.checkPermissions(accessToken, [
"ui.navigation.admin.view",
]);
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 27a2bdf..a26023f 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,7 +1,9 @@
+
+
+ Procurement — Project Optima
+
+
+
diff --git a/src/routes/procurement/+page.svelte b/src/routes/procurement/+page.svelte
new file mode 100644
index 0000000..b13180f
--- /dev/null
+++ b/src/routes/procurement/+page.svelte
@@ -0,0 +1,8 @@
+
diff --git a/src/routes/procurement/catalog/+page.server.ts b/src/routes/procurement/catalog/+page.server.ts
new file mode 100644
index 0000000..1b84ae1
--- /dev/null
+++ b/src/routes/procurement/catalog/+page.server.ts
@@ -0,0 +1,60 @@
+import { optima } from "$lib";
+import { handleApiError } from "$lib/optima-api/errorHandler";
+import { checkPermissions } from "$lib/permissions";
+import type { PageServerLoad } from "./$types";
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const accessToken = locals.session?.accessToken;
+ if (!accessToken) {
+ return {
+ items: [],
+ totalPages: 1,
+ currentPage: 1,
+ totalRecords: 0,
+ search: "",
+ permissions: {},
+ };
+ }
+
+ const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
+ const search = url.searchParams.get("search") || "";
+ const includeInactive = url.searchParams.get("includeInactive") === "true";
+
+ try {
+ const [result, permissions] = await Promise.all([
+ optima.procurement
+ .fetchMany(accessToken, page, search, 30, includeInactive)
+ .catch((err) => {
+ console.error(
+ "Failed to fetch catalog items:",
+ err?.response?.data ?? err?.message ?? err,
+ );
+ return {
+ data: [],
+ meta: {
+ pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
+ },
+ };
+ }),
+ checkPermissions(accessToken, [
+ "procurement.catalog.fetch.many",
+ "procurement.catalog.inventory.refresh",
+ "procurement.catalog.fetch",
+ "procurement.catalog.link",
+ ]),
+ ]);
+
+ return {
+ items: result?.data ?? [],
+ totalPages: result?.meta?.pagination?.totalPages ?? 1,
+ currentPage: result?.meta?.pagination?.currentPage ?? page,
+ totalRecords:
+ result?.meta?.pagination?.totalRecords ?? result?.data?.length ?? 0,
+ search,
+ includeInactive,
+ permissions,
+ };
+ } catch (err) {
+ handleApiError(err);
+ }
+};
diff --git a/src/routes/procurement/catalog/+page.svelte b/src/routes/procurement/catalog/+page.svelte
new file mode 100644
index 0000000..14b3ff4
--- /dev/null
+++ b/src/routes/procurement/catalog/+page.svelte
@@ -0,0 +1,1573 @@
+
+
+
+
+
+ Product Catalog — Procurement — Project Optima
+
+
+{#if !hasAccess}
+
+
+
+
+
+
Access Denied
+
+ You don't have permission to view the Product Catalog. Contact your
+ administrator to request access.
+
+
+{:else}
+
+
+
+
+
+
+
+
+ {#if isSearching && !isUserTyping}
+
+ {/if}
+
+ {#if items.length === 0}
+
+
+
+ {:else}
+
+
+
+ Name
+ Part #
+ Manufacturer
+ Vendor
+ Price
+ Cost
+ On Hand
+ Status
+ Last Updated
+
+
+
+ {#each items as item (item.id)}
+ selectItem(item)}
+ >
+
+
+ {item.name}
+ {#if item.identifier}
+ {item.identifier}
+ {/if}
+
+
+
+ {item.partNumber || "—"}
+
+ {item.manufacturer || "—"}
+
+
+ {item.vendorName || "—"}
+ {#if item.vendorSku}
+ {item.vendorSku}
+ {/if}
+
+
+ {formatCurrency(item.price)}
+ {formatCurrency(item.cost)}
+
+ 0 &&
+ item.onHand <= 3}
+ class:onhand-ok={item.onHand != null && item.onHand > 3}
+ >
+ {item.onHand ?? "—"}
+
+
+
+
+ {item.inactive ? "Inactive" : "Active"}
+
+
+ {formatDate(item.cwLastUpdated || item.updatedAt)}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if selectedItem}
+
+
+
+
+
+
+
+
+
+
+ {selectedItem.inactive ? "Inactive" : "Active"}
+
+ {#if selectedItem.salesTaxable}
+ Taxable
+ {/if}
+
+
+
+
+
+
+
+
+
+ Pricing
+
+
+
+ Price
+ {formatCurrency(selectedItem.price)}
+
+
+ Cost
+ {formatCurrency(selectedItem.cost)}
+
+
+ Margin
+ {computeMargin(
+ selectedItem.price,
+ selectedItem.cost,
+ )}
+
+
+ On Hand
+
+ 0 &&
+ selectedItem.onHand <= 3}
+ class:onhand-ok={selectedItem.onHand != null &&
+ selectedItem.onHand > 3}
+ >
+ {selectedItem.onHand ?? "—"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Details
+
+
+ {#if selectedItem.description}
+
+ Description
+ {selectedItem.description}
+
+ {/if}
+ {#if selectedItem.customerDescription}
+
+ Customer Description
+ {selectedItem.customerDescription}
+
+ {/if}
+
+ Part Number
+ {selectedItem.partNumber || "—"}
+
+
+ Manufacturer
+ {selectedItem.manufacturer || "—"}
+
+
+
+
+
+
+
+
+
+
+
+ Vendor
+
+
+
+ Name
+ {selectedItem.vendorName || "—"}
+
+
+ SKU
+ {selectedItem.vendorSku || "—"}
+
+
+
+
+
+ {#if selectedItem.internalNotes}
+
+
+
+
+
+
+
+
+
+ Internal Notes
+
+
{selectedItem.internalNotes}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Linked Items
+ {#if linkedItems.length > 0}
+ {linkedItems.length}
+ {/if}
+
+ {#if canLink}
+
+
+
+
+
+
+ {/if}
+
+
+ {#if linkedError}
+
{linkedError}
+ {/if}
+
+ {#if linkedLoading}
+
+ {:else if linkedItems.length === 0}
+
No linked items
+ {:else}
+
+ {#each linkedItems as li (li.id)}
+
+
toggleLinkedExpand(li.id)}
+ >
+
+ {li.name}
+ {#if li.identifier}
+ {li.identifier}
+ {/if}
+
+
+
+ {#if expandedLinkedId === li.id}
+
+
+
+ Price
+ {formatCurrency(li.price)}
+
+
+ Cost
+ {formatCurrency(li.cost)}
+
+
+ Margin
+ {computeMargin(li.price, li.cost)}
+
+
+ On Hand
+
+ 0 &&
+ li.onHand <= 3}
+ class:onhand-ok={li.onHand != null &&
+ li.onHand > 3}
+ >
+ {li.onHand ?? "—"}
+
+
+
+
+ Status
+
+
+ {li.inactive ? "Inactive" : "Active"}
+
+
+
+ {#if li.description}
+
+ Description
+ {li.description}
+
+ {/if}
+
+ {#if canLink}
+
+ handleUnlink(li.id)}
+ disabled={unlinkingId === li.id}
+ >
+ {#if unlinkingId === li.id}
+
+ {:else}
+
+
+
+ Unlink
+ {/if}
+
+ {/if}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Timestamps
+
+
+
+ CW Last Updated
+ {formatDate(selectedItem.cwLastUpdated)}
+
+
+ Created
+ {formatDate(selectedItem.createdAt)}
+
+
+ Updated
+ {formatDate(selectedItem.updatedAt)}
+
+ {#if selectedItem.cwCatalogId}
+
+ CW Catalog ID
+ {selectedItem.cwCatalogId}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if linkModalOpen && selectedItem}
+
+
+
+
+
+
+
+
+
+
Linked Items
+ {#if linkedLoading}
+
+ {:else if linkedItems.length === 0}
+
No linked items yet
+ {:else}
+ {#each linkedItems as li (li.id)}
+
setLinkPreview(li)}
+ on:keydown={(e) => handlePreviewKeydown(e, li)}
+ tabindex="0"
+ role="button"
+ >
+
+ {li.name}
+ {#if li.identifier}
+ {li.identifier}
+ {/if}
+
+
+ toggleStageRemove(li.id)}
+ disabled={commitLoading}
+ aria-label="Toggle remove {li.name}"
+ >
+ {#if stagedRemovals.has(li.id)}
+
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+ {/each}
+ {/if}
+
+
+
+
Search Results
+ {#if linkSearchLoading}
+
+ {:else if linkSearchQuery && linkSearchResults.length === 0}
+
No items found
+ {:else}
+ {#each linkSearchResults as result (result.id)}
+
setLinkPreview(result)}
+ on:keydown={(e) => handlePreviewKeydown(e, result)}
+ tabindex="0"
+ role="button"
+ >
+
+ {result.name}
+ {#if result.identifier}
+ {result.identifier}
+ {/if}
+
+
+ toggleStageAdd(result.id)}
+ disabled={commitLoading}
+ aria-label="Toggle add {result.name}"
+ >
+ {#if stagedAdds.has(result.id)}
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+
+
+ {/each}
+ {/if}
+
+
+
+ {#if linkPreviewItem}
+
+
+
+
{linkPreviewItem.name}
+
+
+ Price
+ {formatCurrency(linkPreviewItem.price)}
+
+
+ Cost
+ {formatCurrency(linkPreviewItem.cost)}
+
+
+ Margin
+
+ {computeMargin(
+ linkPreviewItem.price,
+ linkPreviewItem.cost,
+ )}
+
+
+
+ Status
+
+
+ {linkPreviewItem.inactive ? "Inactive" : "Active"}
+
+
+
+
+ On Hand
+
+ 0 &&
+ linkPreviewItem.onHand <= 3}
+ class:onhand-ok={linkPreviewItem.onHand != null &&
+ linkPreviewItem.onHand > 3}
+ >
+ {linkPreviewItem.onHand ?? "—"}
+
+
+
+ {#if linkPreviewItem.manufacturer}
+
+ Manufacturer
+ {linkPreviewItem.manufacturer}
+
+ {/if}
+ {#if linkPreviewItem.partNumber}
+
+ Part #
+ {linkPreviewItem.partNumber}
+
+ {/if}
+ {#if linkPreviewItem.vendorName}
+
+ Vendor
+ {linkPreviewItem.vendorName}
+
+ {/if}
+ {#if linkPreviewItem.vendorSku}
+
+ Vendor SKU
+ {linkPreviewItem.vendorSku}
+
+ {/if}
+ {#if linkPreviewItem.description}
+
+ Description
+ {linkPreviewItem.description}
+
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+
+ {#if totalPages > 1}
+
+ {/if}
+
+{/if}
+
+
diff --git a/src/routes/procurement/catalog/linked/+server.ts b/src/routes/procurement/catalog/linked/+server.ts
new file mode 100644
index 0000000..07480d3
--- /dev/null
+++ b/src/routes/procurement/catalog/linked/+server.ts
@@ -0,0 +1,67 @@
+import { optima } from "$lib";
+import { json, error } from "@sveltejs/kit";
+import type { RequestHandler } from "./$types";
+
+/** GET /procurement/catalog/linked?id= — fetch linked items */
+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 item id");
+
+ try {
+ const result = await optima.procurement.fetchLinkedItems(
+ accessToken,
+ identifier,
+ );
+ return json(result);
+ } catch (err: unknown) {
+ console.error("Failed to fetch linked items:", err);
+ const status =
+ err && typeof err === "object" && "status" in err
+ ? (err as { status: number }).status
+ : 500;
+ throw error(status, "Failed to fetch linked items");
+ }
+};
+
+/** POST /procurement/catalog/linked — link or unlink items */
+export const POST: RequestHandler = async ({ request, locals }) => {
+ const accessToken = locals.session?.accessToken;
+ if (!accessToken) throw error(401, "Unauthorized");
+
+ const body = await request.json();
+ const { action, identifier, targetId } = body;
+
+ if (!identifier || !targetId || !action) {
+ throw error(400, "Missing identifier, targetId, or action");
+ }
+
+ try {
+ if (action === "link") {
+ const result = await optima.procurement.linkItem(
+ accessToken,
+ identifier,
+ targetId,
+ );
+ return json(result);
+ } else if (action === "unlink") {
+ const result = await optima.procurement.unlinkItem(
+ accessToken,
+ identifier,
+ targetId,
+ );
+ return json(result);
+ } else {
+ throw error(400, "Invalid action — must be 'link' or 'unlink'");
+ }
+ } catch (err: unknown) {
+ console.error(`Failed to ${action} items:`, err);
+ const status =
+ err && typeof err === "object" && "status" in err
+ ? (err as { status: number }).status
+ : 500;
+ throw error(status, `Failed to ${action} items`);
+ }
+};
diff --git a/src/routes/procurement/catalog/search/+server.ts b/src/routes/procurement/catalog/search/+server.ts
new file mode 100644
index 0000000..3cfe67e
--- /dev/null
+++ b/src/routes/procurement/catalog/search/+server.ts
@@ -0,0 +1,30 @@
+import { optima } from "$lib";
+import { json, error } from "@sveltejs/kit";
+import type { RequestHandler } from "./$types";
+
+/** GET /procurement/catalog/search?q= — search catalog items for linking */
+export const GET: RequestHandler = async ({ url, locals }) => {
+ const accessToken = locals.session?.accessToken;
+ if (!accessToken) throw error(401, "Unauthorized");
+
+ const query = url.searchParams.get("q") || "";
+ if (!query.trim()) return json({ data: [] });
+
+ try {
+ const result = await optima.procurement.fetchMany(
+ accessToken,
+ 1,
+ query,
+ 20,
+ true,
+ );
+ return json({ data: result?.data ?? [] });
+ } catch (err: unknown) {
+ console.error("Failed to search catalog items:", err);
+ const status =
+ err && typeof err === "object" && "status" in err
+ ? (err as { status: number }).status
+ : 500;
+ throw error(status, "Failed to search catalog items");
+ }
+};
diff --git a/src/routes/sales/+page.server.ts b/src/routes/sales/+page.server.ts
new file mode 100644
index 0000000..9e76052
--- /dev/null
+++ b/src/routes/sales/+page.server.ts
@@ -0,0 +1,79 @@
+import { optima } from "$lib";
+import { handleApiError } from "$lib/optima-api/errorHandler";
+import { checkPermissions } from "$lib/permissions";
+import type { PageServerLoad } from "./$types";
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const accessToken = locals.session?.accessToken;
+ if (!accessToken) {
+ return {
+ opportunities: [],
+ totalPages: 1,
+ currentPage: 1,
+ totalRecords: 0,
+ search: "",
+ includeClosed: true,
+ permissions: {},
+ };
+ }
+
+ const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
+ const search = url.searchParams.get("search") || "";
+ const includeClosed = url.searchParams.get("includeClosed") !== "false";
+
+ try {
+ const [result, permissions] = await Promise.all([
+ optima.sales
+ .fetchMany(accessToken, page, search, 30, includeClosed)
+ .catch((err) => {
+ console.error(
+ "Failed to fetch opportunities:",
+ err?.response?.data ?? err?.message ?? err,
+ );
+ return {
+ data: [],
+ meta: {
+ pagination: { totalPages: 1, currentPage: 1, totalRecords: 0 },
+ },
+ };
+ }),
+ checkPermissions(accessToken, ["sales.opportunity.fetch.many"]),
+ ]);
+
+ console.log("Sales opportunities raw result:", {
+ page,
+ search,
+ includeClosed,
+ resultSummary: {
+ hasData: Boolean(result?.data),
+ keys: result?.data ? Object.keys(result.data) : [],
+ meta: result?.meta ?? result?.data?.meta ?? null,
+ },
+ });
+
+ const opportunities =
+ result?.data?.data ??
+ result?.data?.opportunities ??
+ result?.data ??
+ [];
+ const pagination =
+ result?.meta?.pagination ?? result?.data?.meta?.pagination ?? null;
+
+ console.log("Sales opportunities normalized:", {
+ count: opportunities?.length ?? 0,
+ pagination,
+ });
+
+ return {
+ opportunities,
+ totalPages: pagination?.totalPages ?? 1,
+ currentPage: pagination?.currentPage ?? page,
+ totalRecords: pagination?.totalRecords ?? opportunities.length ?? 0,
+ search,
+ includeClosed,
+ permissions,
+ };
+ } catch (err) {
+ handleApiError(err);
+ }
+};
diff --git a/src/routes/sales/+page.svelte b/src/routes/sales/+page.svelte
new file mode 100644
index 0000000..0832c68
--- /dev/null
+++ b/src/routes/sales/+page.svelte
@@ -0,0 +1,457 @@
+
+
+
+
+
+ Sales — Project Optima
+
+
+{#if !hasAccess}
+
+
+
+
+
+
Access Denied
+
+ You don't have permission to view Sales opportunities. Contact your
+ administrator to request access.
+
+
+{:else}
+
+
+
+
+
+
+ {#if isSearching && !isUserTyping}
+
+ {/if}
+
+ {#if opportunities.length === 0}
+
+
+
+ {:else}
+
+
+
+ Opportunity
+ Company
+ Stage
+ Status
+ Priority
+ Owner
+ Expected Close
+ Updated
+
+
+
+ {#each opportunities as opp (opp.id)}
+
+
+
+ {opp.name}
+ {#if opp.cwOpportunityId}
+ CW #{opp.cwOpportunityId}
+ {/if}
+
+
+ {companyLabel(opp)}
+ {opp.stage?.name || "—"}
+
+
+ {statusLabel(opp)}
+
+
+
+
+ {priorityLabel(opp)}
+
+
+ {ownerLabel(opp)}
+
+ {formatDate(opp.expectedCloseDate)}
+
+
+ {formatDate(opp.cwLastUpdated || opp.updatedAt)}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if totalPages > 1}
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/src/styles/layout.css b/src/styles/layout.css
index 06d5130..d37d191 100644
--- a/src/styles/layout.css
+++ b/src/styles/layout.css
@@ -42,7 +42,7 @@
/* Sidebar */
.sidebar {
- width: 72px;
+ width: 90px;
background-color: var(--bg-surface-alt);
border-right: 1px solid var(--border-default);
box-shadow: none;
@@ -121,10 +121,6 @@
font-size: 11px;
font-weight: 500;
text-align: center;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- max-width: 60px;
}
/* Main Content */
diff --git a/src/styles/procurement.css b/src/styles/procurement.css
new file mode 100644
index 0000000..16d8f2b
--- /dev/null
+++ b/src/styles/procurement.css
@@ -0,0 +1,115 @@
+/* ═══════════════════════════════════════════════════
+ Procurement — Pane + Tab Bar Layout
+ (mirrors admin.css structure)
+ ═══════════════════════════════════════════════════ */
+
+/* Page container */
+.procurement-page {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ width: 100%;
+}
+
+/* ── Pane container ── */
+.procurement-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ background: var(--bg-surface);
+ border-radius: 12px;
+ box-shadow: var(--header-shadow);
+ overflow: hidden;
+}
+
+/* ── Pane header (title + inline tabs) ── */
+.procurement-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 12px 24px 0;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.procurement-header-left {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.procurement-header-icon {
+ width: 18px;
+ height: 18px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.procurement-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+/* ── Tab bar (inline within header) ── */
+.procurement-header .tab-bar {
+ display: flex;
+ align-items: stretch;
+ gap: 0;
+}
+
+.procurement-header .tab-btn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ padding: 10px 14px 9px;
+ border: none;
+ background: none;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: color 0.15s;
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+.procurement-header .tab-btn::after {
+ content: "";
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ border-radius: 2px 2px 0 0;
+ background: transparent;
+ transition: background 0.15s;
+}
+
+.procurement-header .tab-btn:hover {
+ color: var(--text-primary);
+}
+
+.procurement-header .tab-btn.active {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.procurement-header .tab-btn.active::after {
+ background: var(--input-focus-border);
+}
+
+/* ── Pane body (tab content area) ── */
+.procurement-body {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
diff --git a/src/styles/procurement/catalog.css b/src/styles/procurement/catalog.css
new file mode 100644
index 0000000..cbd3a67
--- /dev/null
+++ b/src/styles/procurement/catalog.css
@@ -0,0 +1,1279 @@
+/* ═══════════════════════════════════════════════════
+ Product Catalog — Table + Detail Panel
+ (matches admin table conventions)
+ ═══════════════════════════════════════════════════ */
+
+.catalog-page {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ padding: 16px 24px 0;
+}
+
+/* ── Toolbar (search + count) ── */
+.catalog-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 10px;
+ flex-shrink: 0;
+}
+
+.catalog-toolbar-left {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.catalog-result-count {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+/* ── Search ── */
+.catalog-search-bar {
+ position: relative;
+ width: 260px;
+}
+
+.catalog-search-bar input {
+ width: 100%;
+ padding: 7px 12px 7px 30px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 7px;
+ font-size: 13px;
+ outline: none;
+ background: var(--bg-inset);
+ color: var(--text-primary);
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+
+.catalog-search-bar input::placeholder {
+ color: var(--text-muted);
+}
+
+.catalog-search-bar input:focus {
+ border-color: var(--input-focus-border);
+ box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
+}
+
+.catalog-search-icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 14px;
+ height: 14px;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.catalog-search-clear {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s;
+}
+
+.catalog-search-clear:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+}
+
+/* ── Toolbar right group ── */
+.catalog-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ── Filter button ── */
+.catalog-filter-wrap {
+ position: relative;
+}
+
+.catalog-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 7px;
+ background: var(--bg-inset);
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ color 0.15s,
+ background 0.15s;
+ white-space: nowrap;
+}
+
+.catalog-filter-btn:hover {
+ border-color: var(--input-focus-border);
+ color: var(--text-primary);
+}
+
+.catalog-filter-btn.has-filters {
+ border-color: var(--accent-color, #0066cc);
+ color: var(--accent-color, #0066cc);
+}
+
+.catalog-filter-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: var(--accent-color, #0066cc);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+}
+
+/* ── Filter popover ── */
+.catalog-filter-popover {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 20;
+ min-width: 200px;
+ padding: 8px 4px;
+ border: 1px solid var(--card-border);
+ border-radius: 8px;
+ background: var(--bg-surface);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+ animation: filter-pop-in 0.12s ease;
+}
+
+@keyframes filter-pop-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.catalog-filter-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 5px;
+ font-size: 13px;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: background 0.12s;
+ user-select: none;
+}
+
+.catalog-filter-option:hover {
+ background: var(--card-hover-bg);
+}
+
+.catalog-filter-option input[type="checkbox"] {
+ width: 15px;
+ height: 15px;
+ accent-color: var(--accent-color, #0066cc);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+/* ── Content area: table + panel side-by-side ── */
+.catalog-content {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ border: 1px solid var(--card-border);
+ border-radius: 10px;
+}
+
+/* ── Table wrapper — scrolls independently ── */
+.catalog-table-wrapper {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+}
+
+/* ── Loading overlay ── */
+.catalog-loading-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-surface-overlay, rgba(255, 255, 255, 0.6));
+ z-index: 10;
+}
+
+.catalog-spinner {
+ width: 28px;
+ height: 28px;
+ border: 3px solid var(--border-subtle);
+ border-top-color: var(--accent-color, #0066cc);
+ border-radius: 50%;
+ animation: catalog-spin 0.7s linear infinite;
+}
+
+@keyframes catalog-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ── Empty state ── */
+.catalog-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+}
+
+/* ── Table (matches admin-table) ── */
+.catalog-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.catalog-table thead {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: var(--bg-inset);
+}
+
+.catalog-table th {
+ padding: 10px 16px;
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--card-border);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.catalog-table td {
+ padding: 12px 16px;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--border-subtle);
+ vertical-align: middle;
+}
+
+.catalog-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.catalog-row {
+ transition: background 0.15s;
+ cursor: pointer;
+}
+
+.catalog-row:hover {
+ background: var(--card-hover-bg);
+}
+
+.catalog-row.selected-row {
+ background: var(--accent-subtle, rgba(0, 102, 204, 0.06));
+ box-shadow: inset 3px 0 0 var(--accent-color, #0066cc);
+}
+
+.catalog-row.selected-row:hover {
+ background: var(--accent-subtle-hover, rgba(0, 102, 204, 0.1));
+ box-shadow: inset 3px 0 0 var(--accent-color, #0066cc);
+}
+
+.catalog-row.inactive-row {
+ opacity: 0.55;
+}
+
+/* ── Column widths ── */
+.col-name {
+ min-width: 200px;
+}
+
+.col-part,
+.col-manufacturer,
+.col-vendor {
+ min-width: 120px;
+}
+
+.col-price,
+.col-cost {
+ min-width: 90px;
+ text-align: right;
+}
+
+th.col-price,
+th.col-cost {
+ text-align: right;
+}
+
+.col-onhand {
+ min-width: 70px;
+ text-align: center;
+}
+
+th.col-onhand {
+ text-align: center;
+}
+
+.col-status {
+ min-width: 80px;
+ text-align: center;
+}
+
+th.col-status {
+ text-align: center;
+}
+
+.col-updated {
+ min-width: 110px;
+ white-space: nowrap;
+}
+
+/* ── Cell helpers ── */
+.item-name-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.item-name {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.item-desc {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.3;
+}
+
+.vendor-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.vendor-sku {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.mono {
+ font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
+ font-size: 12px;
+}
+
+/* ── On-hand badge ── */
+.onhand-badge {
+ display: inline-block;
+ min-width: 28px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.onhand-zero {
+ background: var(--status-inactive-bg, #fee2e2);
+ color: var(--status-inactive-color, #dc2626);
+}
+
+.onhand-low {
+ background: var(--status-pending-bg, #fef3c7);
+ color: var(--status-pending-color, #d97706);
+}
+
+.onhand-ok {
+ background: var(--status-active-bg, #dcfce7);
+ color: var(--status-active-color, #16a34a);
+}
+
+/* ── Status badge ── */
+.status-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.status-active {
+ background: var(--status-active-bg, #dcfce7);
+ color: var(--status-active-color, #16a34a);
+}
+
+.status-inactive {
+ background: var(--status-inactive-bg, #fee2e2);
+ color: var(--status-inactive-color, #dc2626);
+}
+
+/* ── Pagination footer ── */
+.catalog-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ flex-shrink: 0;
+}
+
+.catalog-page-info {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.catalog-pagination {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.catalog-page-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+}
+
+.catalog-page-btn:hover:not(:disabled) {
+ background: var(--hover-bg);
+ border-color: var(--input-focus-border);
+}
+
+.catalog-page-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.catalog-page-btn.active {
+ background: var(--accent-color, #0066cc);
+ color: #fff;
+ border-color: var(--accent-color, #0066cc);
+}
+
+.catalog-page-ellipsis {
+ padding: 0 4px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+/* ═══════════════════════════════════════════════════
+ Detail Panel — slides in from right, scrolls independently
+ ═══════════════════════════════════════════════════ */
+
+.detail-panel {
+ width: 380px;
+ min-width: 380px;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-inset);
+ border-left: 1px solid var(--card-border);
+ overflow: hidden;
+}
+
+/* ── Panel header ── */
+.detail-panel-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+ flex-shrink: 0;
+}
+
+.detail-panel-title-wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.detail-panel-title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.3;
+ word-break: break-word;
+}
+
+.detail-panel-identifier {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.detail-panel-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ flex-shrink: 0;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s;
+}
+
+.detail-panel-close:hover {
+ background: var(--hover-bg);
+ color: var(--text-primary);
+}
+
+/* ── Panel body — scrolls independently ── */
+.detail-panel-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0;
+}
+
+/* ── Sections ── */
+.detail-section {
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.detail-section:last-child {
+ border-bottom: none;
+}
+
+.detail-status-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.detail-tag {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+}
+
+.detail-section-heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0 0 10px 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.detail-section-heading svg {
+ color: var(--text-muted);
+}
+
+/* ── Field grid ── */
+.detail-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px 16px;
+}
+
+.detail-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.detail-field-full {
+ grid-column: 1 / -1;
+}
+
+.detail-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.detail-value {
+ font-size: 13px;
+ color: var(--text-primary);
+ word-break: break-word;
+}
+
+.detail-notes {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-primary);
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+
+/* ═══════════════════════════════════════════════════
+ Linked Items — section within detail panel
+ ═══════════════════════════════════════════════════ */
+
+.detail-section-heading-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.detail-section-heading-row .detail-section-heading {
+ margin-bottom: 0;
+}
+
+.linked-count {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: var(--border-subtle);
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+}
+
+.linked-add-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ color 0.15s,
+ background 0.15s;
+}
+
+.linked-add-btn:hover {
+ border-color: var(--accent-color, #0066cc);
+ color: var(--accent-color, #0066cc);
+ background: var(--accent-subtle, rgba(0, 102, 204, 0.06));
+}
+
+.linked-error {
+ margin: 0;
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--status-inactive-color, #dc2626);
+ background: var(--status-inactive-bg, #fee2e2);
+}
+
+.linked-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px 0;
+}
+
+.linked-empty {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-muted);
+ text-align: center;
+ padding: 8px 0;
+}
+
+.linked-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.linked-item-wrap {
+ border-radius: 6px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ overflow: hidden;
+ transition: border-color 0.15s;
+}
+
+.linked-item-wrap:hover {
+ border-color: var(--card-border);
+}
+
+.linked-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 10px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.12s;
+}
+
+.linked-item:hover {
+ background: var(--card-hover-bg);
+}
+
+.linked-item.linked-item-expanded {
+ background: var(--card-hover-bg);
+}
+
+.linked-item-inactive {
+ opacity: 0.55;
+}
+
+.linked-item-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.linked-item-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.linked-item-id {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.linked-item-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.linked-item-price {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.linked-chevron {
+ flex-shrink: 0;
+ color: var(--text-muted);
+ transition: transform 0.15s ease;
+}
+
+.linked-chevron-open {
+ transform: rotate(180deg);
+}
+
+/* ── Expanded linked detail ── */
+.linked-detail {
+ padding: 8px 10px 10px;
+ border-top: 1px solid var(--border-subtle);
+ background: var(--bg-inset);
+}
+
+.linked-detail-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px 12px;
+}
+
+.linked-detail-field {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+
+.linked-detail-full {
+ grid-column: 1 / -1;
+}
+
+.linked-unlink-btn-full {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ width: 100%;
+ margin-top: 10px;
+ padding: 5px 0;
+ border: 1px solid var(--border-subtle);
+ border-radius: 5px;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ color 0.15s,
+ border-color 0.15s;
+}
+
+.linked-unlink-btn-full:hover {
+ background: var(--status-inactive-bg, #fee2e2);
+ color: var(--status-inactive-color, #dc2626);
+ border-color: var(--status-inactive-color, #dc2626);
+}
+
+.linked-unlink-btn-full:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ═══════════════════════════════════════════════════
+ Link Modal — overlay for searching & linking items
+ ═══════════════════════════════════════════════════ */
+
+.link-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 80px;
+ background: rgba(0, 0, 0, 0.35);
+ animation: link-overlay-in 0.12s ease;
+}
+
+@keyframes link-overlay-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.link-modal {
+ width: 760px;
+ max-width: 92vw;
+ height: 560px;
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ background: var(--bg-surface);
+ border: 1px solid var(--card-border);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+ overflow: hidden;
+ animation: link-modal-in 0.15s ease;
+}
+
+@keyframes link-modal-in {
+ from {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.link-modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.link-modal-header-text {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+.link-modal-title {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.link-modal-context {
+ font-size: 12px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.link-modal-context strong {
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.link-modal-search {
+ position: relative;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.link-modal-search-icon {
+ position: absolute;
+ left: 26px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 14px;
+ height: 14px;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.link-modal-search input {
+ width: 100%;
+ padding: 7px 12px 7px 30px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 7px;
+ font-size: 13px;
+ outline: none;
+ background: var(--bg-inset);
+ color: var(--text-primary);
+ transition:
+ border-color 0.15s,
+ box-shadow 0.15s;
+}
+
+.link-modal-search input::placeholder {
+ color: var(--text-muted);
+}
+
+.link-modal-search input:focus {
+ border-color: var(--input-focus-border);
+ box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
+}
+
+.link-modal-body {
+ display: flex;
+ min-height: 260px;
+ flex: 1;
+ overflow: hidden;
+}
+
+.link-modal-pane {
+ flex: 1 1 55%;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.link-modal-body.has-preview .link-modal-pane {
+ border-right: 1px solid var(--border-subtle);
+}
+
+.link-modal-results {
+ flex: 1;
+ overflow-y: auto;
+ padding: 6px;
+ min-height: 100px;
+}
+
+.link-modal-section {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 6px;
+}
+
+.link-modal-section-title {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
+.link-modal-divider {
+ height: 1px;
+ margin: 2px 10px;
+ background: var(--border-subtle);
+}
+
+.link-result-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 10px;
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ transition: background 0.12s;
+}
+
+.link-result-row:hover {
+ background: var(--card-hover-bg);
+}
+
+.link-result-selected {
+ background: var(--card-hover-bg);
+}
+
+.link-result-pending {
+ outline: 1px dashed var(--accent-subtle, rgba(0, 102, 204, 0.35));
+}
+
+.link-result-inactive {
+ opacity: 0.55;
+}
+
+.link-result-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.link-result-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.link-result-id {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.link-result-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ color: var(--accent-color, #0066cc);
+}
+
+.link-action-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border-radius: 6px;
+ border: 1px solid transparent;
+ background: var(--bg-inset);
+ color: var(--accent-color, #0066cc);
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ border-color 0.15s,
+ color 0.15s;
+}
+
+.link-action-btn:hover {
+ border-color: var(--accent-subtle, rgba(0, 102, 204, 0.35));
+}
+
+.link-action-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.link-action-remove {
+ color: var(--status-inactive-color, #dc2626);
+}
+
+.link-action-active {
+ background: var(--accent-subtle, rgba(0, 102, 204, 0.15));
+ border-color: var(--accent-color, #0066cc);
+ color: var(--accent-color, #0066cc);
+}
+
+.link-modal-preview {
+ flex: 1 1 45%;
+ padding: 14px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ background: var(--bg-inset);
+ min-width: 0;
+}
+
+.link-preview-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.link-preview-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.link-preview-id {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.link-preview-name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.link-preview-content {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.link-preview-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px 12px;
+}
+
+.link-preview-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.link-preview-full {
+ grid-column: 1 / -1;
+}
+
+.link-modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 12px 16px;
+ border-top: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+.link-modal-warning {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.35);
+ color: #d97706;
+ font-size: 12px;
+ font-weight: 500;
+ margin-bottom: 4px;
+}
+
+.link-modal-warning svg {
+ flex-shrink: 0;
+}
+
+.link-modal-summary {
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.link-modal-summary-divider {
+ color: var(--text-muted);
+}
+
+.link-modal-commit {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--accent-color, #0066cc);
+ background: var(--accent-color, #0066cc);
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+.link-modal-commit:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
diff --git a/src/styles/sales/sales.css b/src/styles/sales/sales.css
new file mode 100644
index 0000000..ab4ed7c
--- /dev/null
+++ b/src/styles/sales/sales.css
@@ -0,0 +1,468 @@
+/* ═══════════════════════════════════════════════════
+ Sales — Opportunities Table
+ ═══════════════════════════════════════════════════ */
+
+.sales-page {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ width: 100%;
+}
+
+.sales-pane {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ background: var(--bg-surface);
+ border-radius: 12px;
+ box-shadow: var(--header-shadow);
+ overflow: hidden;
+}
+
+.sales-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 18px 24px 14px;
+ border-bottom: 1px solid var(--border-subtle);
+ flex-shrink: 0;
+}
+
+.sales-header-left {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.sales-title {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.sales-result-count {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.sales-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* ── Search ── */
+.sales-search-bar {
+ position: relative;
+ width: 280px;
+}
+
+.sales-search-bar input {
+ width: 100%;
+ padding: 9px 34px 9px 38px;
+ border: 1px solid var(--input-border);
+ border-radius: 8px;
+ font-size: 14px;
+ outline: none;
+ background: var(--input-bg);
+ color: var(--input-text);
+ transition:
+ border-color 0.2s,
+ box-shadow 0.2s;
+}
+
+.sales-search-bar input::placeholder {
+ color: var(--input-placeholder);
+}
+
+.sales-search-bar input:focus {
+ border-color: var(--input-focus-border);
+ box-shadow: 0 0 0 3px var(--input-focus-ring);
+ background: var(--input-focus-bg);
+}
+
+.sales-search-icon {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ color: var(--text-faint);
+ pointer-events: none;
+}
+
+.sales-search-clear {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ background: none;
+ border: none;
+ color: var(--text-faint);
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition:
+ color 0.15s,
+ background 0.15s;
+}
+
+.sales-search-clear:hover {
+ color: var(--input-text);
+ background: var(--nav-hover-bg);
+}
+
+/* ── Filter button ── */
+.sales-filter-wrap {
+ position: relative;
+}
+
+.sales-filter-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 12px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ background: var(--bg-inset);
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ color 0.15s,
+ background 0.15s;
+ white-space: nowrap;
+}
+
+.sales-filter-btn:hover {
+ border-color: var(--input-focus-border);
+ color: var(--text-primary);
+}
+
+.sales-filter-btn.has-filters {
+ border-color: var(--accent-color, #0066cc);
+ color: var(--accent-color, #0066cc);
+}
+
+.sales-filter-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: var(--accent-color, #0066cc);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+}
+
+.sales-filter-popover {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 20;
+ min-width: 220px;
+ padding: 8px 4px;
+ border: 1px solid var(--card-border);
+ border-radius: 8px;
+ background: var(--bg-surface);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+}
+
+.sales-filter-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 5px;
+ font-size: 13px;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: background 0.12s;
+ user-select: none;
+}
+
+.sales-filter-option:hover {
+ background: var(--card-hover-bg);
+}
+
+.sales-filter-option input[type="checkbox"] {
+ width: 15px;
+ height: 15px;
+ accent-color: var(--accent-color, #0066cc);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.sales-body {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ padding: 16px 24px 8px;
+}
+
+.sales-table-wrap {
+ position: relative;
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ border: 1px solid var(--card-border);
+ border-radius: 10px;
+}
+
+.sales-loading-overlay {
+ position: absolute;
+ inset: 0;
+ background: var(--overlay-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10;
+ backdrop-filter: blur(2px);
+}
+
+.sales-spinner {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 4px solid var(--spinner-track);
+ border-top-color: var(--spinner-accent);
+ animation: sales-spin 0.7s linear infinite;
+}
+
+@keyframes sales-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.sales-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 48px 16px;
+ min-height: 200px;
+}
+
+/* ── Table ── */
+.sales-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.sales-table thead {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ background: var(--bg-inset);
+}
+
+.sales-table th {
+ padding: 10px 16px;
+ text-align: left;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ border-bottom: 1px solid var(--card-border);
+ white-space: nowrap;
+}
+
+.sales-table td {
+ padding: 12px 16px;
+ color: var(--text-primary);
+ border-bottom: 1px solid var(--border-subtle);
+ vertical-align: middle;
+}
+
+.sales-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.sales-row {
+ transition: background 0.15s;
+}
+
+.sales-row:hover {
+ background: var(--card-hover-bg);
+}
+
+.sales-row.closed-row {
+ opacity: 0.65;
+}
+
+/* ── Column widths ── */
+.col-opportunity {
+ min-width: 220px;
+}
+
+.col-company {
+ min-width: 160px;
+}
+
+.col-stage,
+.col-status,
+.col-priority {
+ min-width: 120px;
+}
+
+.col-owner {
+ min-width: 150px;
+}
+
+.col-close,
+.col-updated {
+ min-width: 120px;
+ white-space: nowrap;
+}
+
+.sales-opportunity {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.opp-name {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.opp-meta {
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+.mono {
+ font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
+ font-size: 12px;
+}
+
+.sales-status-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.sales-status-badge.status-open {
+ background: var(--status-active-bg, #dcfce7);
+ color: var(--status-active-color, #16a34a);
+}
+
+.sales-status-badge.status-closed {
+ background: var(--status-inactive-bg, #fee2e2);
+ color: var(--status-inactive-color, #dc2626);
+}
+
+.sales-priority {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.sales-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 24px 18px;
+ flex-shrink: 0;
+}
+
+.sales-page-info {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.sales-pagination {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.sales-page-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 32px;
+ height: 32px;
+ padding: 0 8px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
+}
+
+.sales-page-btn:hover:not(:disabled) {
+ background: var(--hover-bg);
+ border-color: var(--input-focus-border);
+}
+
+.sales-page-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.sales-page-btn.active {
+ background: var(--accent-color, #0066cc);
+ color: #fff;
+ border-color: var(--accent-color, #0066cc);
+}
+
+.sales-page-ellipsis {
+ padding: 0 4px;
+ color: var(--text-muted);
+ font-size: 14px;
+}
+
+@media (max-width: 1024px) {
+ .sales-search-bar {
+ width: 220px;
+ }
+}
+
+@media (max-width: 768px) {
+ .sales-header-actions {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .sales-search-bar {
+ flex: 1;
+ }
+
+ .sales-footer {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+}