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 + + +
+
+ +
+
+ + + + + +

Procurement

+
+ +
+ + +
+ +
+
+
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 totalRecords > 0} + + {totalRecords} item{totalRecords === 1 ? "" : "s"} + + {/if} +
+
+ + + +
+ + + {#if filterOpen} +
+ +
+ {/if} +
+
+
+ + +
+ +
+ {#if isSearching && !isUserTyping} +
+
+
+ {/if} + + {#if items.length === 0} +
+ +
+ {:else} + + + + + + + + + + + + + + + + {#each items as item (item.id)} + selectItem(item)} + > + + + + + + + + + + + {/each} + +
NamePart #ManufacturerVendorPriceCostOn HandStatusLast Updated
+
+ {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)}
+ {/if} +
+ + + {#if selectedItem} +
+ +
+
+

{selectedItem.name}

+ {#if selectedItem.identifier} + {selectedItem.identifier} + {/if} +
+ +
+ + +
+ +
+
+ + {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)} +
+ + {#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} + + {/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} + + {/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} +
+
+
+
+

Sales Opportunities

+ {#if totalRecords > 0} + + {totalRecords} record{totalRecords === 1 ? "" : "s"} + + {/if} +
+
+ + +
+ + + {#if filterOpen} +
+ +
+ {/if} +
+
+
+ +
+
+ {#if isSearching && !isUserTyping} +
+
+
+ {/if} + + {#if opportunities.length === 0} +
+ +
+ {:else} + + + + + + + + + + + + + + + {#each opportunities as opp (opp.id)} + + + + + + + + + + + {/each} + +
OpportunityCompanyStageStatusPriorityOwnerExpected CloseUpdated
+
+ {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)} +
+ {/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; + } +}