feat: add procurement and sales sections

This commit is contained in:
2026-02-27 14:42:19 -06:00
parent 7486bcf939
commit 5a6970a4c5
24 changed files with 4739 additions and 134 deletions
+115 -87
View File
@@ -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.
+19
View File
@@ -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
+125 -3
View File
@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Service Unavailable — Project Optima</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f1117;
color: #e4e4e7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.container {
text-align: center;
max-width: 480px;
padding: 2rem;
}
.icon {
margin-bottom: 1.5rem;
}
.icon svg {
width: 96px;
height: 96px;
}
h1 {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.75rem;
color: #f87171;
}
p {
font-size: 1rem;
line-height: 1.6;
color: #a1a1aa;
margin-bottom: 1.5rem;
}
.retry-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
background: #dc2626;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
text-decoration: none;
}
.retry-btn:hover { background: #b91c1c; }
.status {
margin-top: 2rem;
font-size: 0.8rem;
color: #52525b;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
<svg viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="48" cy="48" r="44" stroke="#dc2626" stroke-width="3" opacity="0.2"/>
<circle cx="48" cy="48" r="32" fill="#1c1c22"/>
<path d="M36 36L60 60M60 36L36 60" stroke="#dc2626" stroke-width="4" stroke-linecap="round"/>
</svg>
</div>
<h1>Unable to Reach API</h1>
<p>
Project Optima cannot connect to the API server. This may be due to a
network issue or the API server being temporarily unavailable.
</p>
<a class="retry-btn" onclick="window.location.reload()" role="button" tabindex="0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Retry
</a>
<p class="status">If this persists, contact your system administrator.</p>
</div>
</body>
</html>`;
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");
}
+4
View File
@@ -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
+35 -1
View File
@@ -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<string, unknown>;
const responseData = (axiosErr?.response as Record<string, unknown>)
?.data as Record<string, unknown> | 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,
+104
View File
@@ -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<string, unknown> = { 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<string, string> = {};
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<string, string> = {};
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;
},
};
+62
View File
@@ -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<string, unknown> = { 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;
},
};
+3
View File
@@ -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",
]);
+13
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import { optima } from "$lib";
import { page } from "$app/stores";
import { navigating } from "$app/stores";
import { theme } from "$lib/theme";
import LoadingSpinner from "../components/LoadingSpinner.svelte";
const navItems = [
{
@@ -15,6 +17,16 @@
label: "Companies",
icon: '<path d="M3 21h18"></path><path d="M5 21V7l8-4v18"></path><path d="M19 21V11l-6-4"></path><path d="M9 9h1"></path><path d="M9 13h1"></path><path d="M9 17h1"></path>',
},
{
href: "/procurement",
label: "Procurement",
icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line>',
},
{
href: "/sales",
label: "Sales",
icon: '<line x1="12" y1="1" x2="12" y2="23"></line><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>',
},
];
const adminNavItem = {
@@ -33,6 +45,7 @@
{#if $page.route.id?.startsWith("/(auth)")}
<slot />
{:else}
<LoadingSpinner loading={!!$navigating} />
<div class="layout-container">
<header class="header">
<div class="header-content">
+18 -12
View File
@@ -4,31 +4,37 @@ import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ locals }) => {
export const load: LayoutServerLoad = async ({ locals, parent }) => {
const accessToken = locals.session?.accessToken;
if (!accessToken) {
throw redirect(303, "/login");
}
try {
// Check the top-level admin gate + all per-tab permissions in one call
const permissions = await checkPermissions(accessToken, [
"ui.navigation.admin.view",
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
]);
// Grab the root layout data to reuse the admin-view permission it already checked
const parentData = await parent();
if (!permissions["ui.navigation.admin.view"]) {
// If the root layout already determined we can't view admin, redirect immediately
if (parentData?.canViewAdmin === false) {
throw redirect(303, "/");
}
// Fetch current user info for the dashboard greeting
const userInfo = await optima.user.fetchInfo(accessToken);
// Fetch sub-tab permissions and user info in parallel
const [permissions, userInfo] = await Promise.all([
checkPermissions(accessToken, [
"admin.users.view",
"admin.roles.view",
"admin.credential-types.view",
]),
optima.user.fetchInfo(accessToken),
]);
return {
user: userInfo?.data ?? null,
permissions,
permissions: {
"ui.navigation.admin.view": true, // Already verified via parent
...permissions,
},
};
} catch (err) {
// Re-throw redirects so SvelteKit handles them
+19 -6
View File
@@ -30,6 +30,7 @@
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
let isUserTyping = false;
// When navigation completes (results loaded), clear loading & refocus
// Ensure spinner stays visible for at least 500ms
@@ -38,6 +39,7 @@
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
isUserTyping = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
@@ -53,30 +55,41 @@
const params = new URLSearchParams();
params.set("page", String(p));
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
goto(`/companies?${params.toString()}`, { replaceState: true });
}
function handleSearch() {
isSearching = true;
isUserTyping = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
isSearching = true;
isUserTyping = false;
const params = new URLSearchParams();
params.set("page", "1");
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
}, 300);
goto(`/companies?${params.toString()}`, {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}, 500);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
isSearching = true;
isUserTyping = false;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
const params = new URLSearchParams();
params.set("page", "1");
if (searchInput) params.set("search", searchInput);
goto(`/companies?${params.toString()}`);
goto(`/companies?${params.toString()}`, {
replaceState: true,
keepFocus: true,
noScroll: true,
});
}
}
@@ -208,7 +221,7 @@
<!-- Pane body -->
<div class="pane-body">
{#if isSearching}
{#if isSearching && !isUserTyping}
<div class="search-loading-overlay">
<div class="search-spinner"></div>
</div>
+21 -20
View File
@@ -1,6 +1,6 @@
import { optima } from "$lib";
import { handleApiError } from "$lib/optima-api/errorHandler";
import { checkPermissions, type PermissionMap } from "$lib/permissions";
import { resolvePermissions, type PermissionMap } from "$lib/permissions";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals, params }) => {
@@ -18,40 +18,41 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}
try {
// Run permission checks in parallel with other data fetches.
// Add any new permissions the company page needs to this array.
// Permissions are resolved locally from the Set populated in hooks — no API call
const permissions = resolvePermissions(locals.userPermissions, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]);
// All data fetches can now run in parallel — no permissions waterfall
const [
permissions,
companyResult,
configsResult,
credentialsResult,
credentialTypesResult,
unifiSitesResult,
] = await Promise.all([
checkPermissions(accessToken, [
"company.fetch.address",
"company.fetch.contacts",
"credential.secure_values.read",
"unifi.site.wifi",
"unifi.site.wifi.read.name",
"unifi.site.wifi.update",
]),
optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
}),
optima.company.fetchConfigurations(accessToken, params.id),
optima.credential
.fetchByCompany(accessToken, params.id)
.catch(() => ({ data: [] })),
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
optima.credentialType
.fetchMany(accessToken)
.catch(() => ({ data: [] })),
optima.unifi
.fetchCompanySites(accessToken, params.id)
.catch(() => ({ data: [] })),
]);
// Fetch company with or without address based on permission
const companyResult = await optima.company.fetch(accessToken, params.id, {
includeAddress: permissions["company.fetch.address"] === true,
includePrimaryContact: true,
includeAllContacts: permissions["company.fetch.contacts"] === true,
});
return {
company: companyResult?.data ?? null,
configurations: configsResult?.data ?? [],
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { page } from "$app/stores";
import "../../styles/procurement.css";
const tabs = [
{ label: "Product Catalog", href: "/procurement/catalog", exact: false },
] as const;
function isActive(
tab: { href: string; exact?: boolean },
pathname: string,
): boolean {
if (tab.exact) return pathname === tab.href;
return pathname.startsWith(tab.href);
}
</script>
<svelte:head>
<title>Procurement — Project Optima</title>
</svelte:head>
<div class="procurement-page">
<div class="procurement-pane">
<!-- Pane header + tabs in one row -->
<div class="procurement-header">
<div class="procurement-header-left">
<svg
class="procurement-header-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
<h2 class="procurement-title">Procurement</h2>
</div>
<nav class="tab-bar" role="tablist">
{#each tabs as tab}
<a
href={tab.href}
class="tab-btn"
class:active={isActive(tab, $page.url.pathname)}
role="tab"
aria-selected={isActive(tab, $page.url.pathname)}
>
{tab.label}
</a>
{/each}
</nav>
</div>
<!-- Tab content -->
<div class="procurement-body">
<slot />
</div>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { onMount } from "svelte";
onMount(() => {
goto("/procurement/catalog", { replaceState: true });
});
</script>
@@ -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);
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /procurement/catalog/linked?id=<identifier> — 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`);
}
};
@@ -0,0 +1,30 @@
import { optima } from "$lib";
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
/** GET /procurement/catalog/search?q=<query> — 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");
}
};
+79
View File
@@ -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);
}
};
+457
View File
@@ -0,0 +1,457 @@
<script lang="ts">
import { goto, afterNavigate } from "$app/navigation";
import type { PermissionMap } from "$lib/permissions";
import NoResultsMonkey from "../../components/NoResultsMonkey.svelte";
import "../../styles/sales/sales.css";
type SalesOpportunity = {
id: string;
cwOpportunityId?: number;
name: string;
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;
primarySalesRep?: { id?: number; identifier?: string; name?: string } | null;
secondarySalesRep?: { id?: number; identifier?: string; name?: string } | null;
company?: { id?: number | string; name?: string } | null;
expectedCloseDate?: string | null;
closedDate?: string | null;
closedFlag?: boolean;
cwLastUpdated?: string | null;
createdAt?: string;
updatedAt?: string;
};
export let data: {
permissions: PermissionMap;
opportunities: SalesOpportunity[];
totalPages: number;
currentPage: number;
totalRecords: number;
search: string;
includeClosed: boolean;
};
$: hasAccess = data.permissions["sales.opportunity.fetch.many"] === true;
let searchInput = data.search;
let debounceTimer: ReturnType<typeof setTimeout>;
let isSearching = false;
let searchInputEl: HTMLInputElement;
let searchStartedAt = 0;
let isUserTyping = false;
let showClosed = data.includeClosed;
let filterOpen = false;
let filterBtnEl: HTMLButtonElement;
let filterPopoverEl: HTMLDivElement;
function toggleFilterPopover() {
filterOpen = !filterOpen;
}
function handleFilterClickOutside(e: MouseEvent) {
if (!filterOpen) return;
const target = e.target as Node;
if (filterBtnEl?.contains(target) || filterPopoverEl?.contains(target))
return;
filterOpen = false;
}
function toggleClosed() {
showClosed = !showClosed;
filterOpen = false;
navigateWithFilters({ page: 1 });
}
$: activeFilterCount = showClosed ? 0 : 1;
afterNavigate(() => {
const elapsed = Date.now() - searchStartedAt;
const remaining = Math.max(0, 500 - elapsed);
setTimeout(() => {
isSearching = false;
isUserTyping = false;
if (searchInputEl && document.activeElement !== searchInputEl) {
requestAnimationFrame(() => searchInputEl?.focus());
}
}, remaining);
});
$: currentPage = data.currentPage;
$: totalPages = data.totalPages;
$: totalRecords = data.totalRecords;
$: opportunities = data.opportunities;
function navigateWithFilters(
opts: { page?: number; keepFocus?: boolean } = {},
) {
const params = new URLSearchParams();
params.set("page", String(opts.page ?? currentPage));
if (searchInput) params.set("search", searchInput);
if (!showClosed) params.set("includeClosed", "false");
goto(`/sales?${params.toString()}`, {
replaceState: true,
keepFocus: opts.keepFocus ?? false,
noScroll: true,
});
}
function navigateToPage(p: number) {
navigateWithFilters({ page: p });
}
function handleSearch() {
isUserTyping = true;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
isSearching = true;
isUserTyping = false;
navigateWithFilters({ page: 1, keepFocus: true });
}, 500);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
isSearching = true;
isUserTyping = false;
searchStartedAt = Date.now();
clearTimeout(debounceTimer);
navigateWithFilters({ page: 1, keepFocus: true });
}
}
function formatDate(dateStr?: string | null): string {
if (!dateStr) return "—";
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "—";
}
}
function statusLabel(op: SalesOpportunity): string {
if (op.closedFlag) return "Closed";
return op.status?.name || "Open";
}
function ownerLabel(op: SalesOpportunity): string {
return (
op.primarySalesRep?.name ||
op.secondarySalesRep?.name ||
"—"
);
}
function companyLabel(op: SalesOpportunity): string {
return op.company?.name || "—";
}
function priorityLabel(op: SalesOpportunity): string {
return op.priority?.name || "—";
}
function getPageNumbers(current: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const pages: (number | "...")[] = [1];
if (current > 3) pages.push("...");
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
for (let i = start; i <= end; i++) pages.push(i);
if (current < total - 2) pages.push("...");
pages.push(total);
return pages;
}
$: pageNumbers = getPageNumbers(currentPage, totalPages);
</script>
<svelte:window on:click={handleFilterClickOutside} />
<svelte:head>
<title>Sales — Project Optima</title>
</svelte:head>
{#if !hasAccess}
<div class="sales-access-denied">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
</svg>
<h3>Access Denied</h3>
<p>
You don't have permission to view Sales opportunities. Contact your
administrator to request access.
</p>
</div>
{:else}
<div class="sales-page">
<div class="sales-pane">
<div class="sales-header">
<div class="sales-header-left">
<h2 class="sales-title">Sales Opportunities</h2>
{#if totalRecords > 0}
<span class="sales-result-count">
{totalRecords} record{totalRecords === 1 ? "" : "s"}
</span>
{/if}
</div>
<div class="sales-header-actions">
<div class="sales-search-bar">
<svg
class="sales-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
type="text"
placeholder="Search opportunities…"
bind:this={searchInputEl}
bind:value={searchInput}
on:input={handleSearch}
on:keydown={handleKeydown}
/>
{#if searchInput}
<button
class="sales-search-clear"
on:click={() => {
searchInput = "";
handleSearch();
}}
aria-label="Clear search"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<div class="sales-filter-wrap">
<button
class="sales-filter-btn"
class:has-filters={activeFilterCount > 0}
bind:this={filterBtnEl}
on:click={toggleFilterPopover}
aria-label="Filters"
aria-expanded={filterOpen}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filters
{#if activeFilterCount > 0}
<span class="sales-filter-badge">{activeFilterCount}</span>
{/if}
</button>
{#if filterOpen}
<div class="sales-filter-popover" bind:this={filterPopoverEl}>
<label class="sales-filter-option">
<input
type="checkbox"
checked={showClosed}
on:change={toggleClosed}
/>
<span>Include closed opportunities</span>
</label>
</div>
{/if}
</div>
</div>
</div>
<div class="sales-body">
<div class="sales-table-wrap">
{#if isSearching && !isUserTyping}
<div class="sales-loading-overlay">
<div class="sales-spinner"></div>
</div>
{/if}
{#if opportunities.length === 0}
<div class="sales-empty">
<NoResultsMonkey
message={searchInput
? "No opportunities match your search"
: "No opportunities found"}
/>
</div>
{:else}
<table class="sales-table">
<thead>
<tr>
<th class="col-opportunity">Opportunity</th>
<th class="col-company">Company</th>
<th class="col-stage">Stage</th>
<th class="col-status">Status</th>
<th class="col-priority">Priority</th>
<th class="col-owner">Owner</th>
<th class="col-close">Expected Close</th>
<th class="col-updated">Updated</th>
</tr>
</thead>
<tbody>
{#each opportunities as opp (opp.id)}
<tr class="sales-row" class:closed-row={opp.closedFlag}>
<td class="col-opportunity">
<div class="sales-opportunity">
<span class="opp-name">{opp.name}</span>
{#if opp.cwOpportunityId}
<span class="opp-meta mono"
>CW #{opp.cwOpportunityId}</span
>
{/if}
</div>
</td>
<td class="col-company">{companyLabel(opp)}</td>
<td class="col-stage">{opp.stage?.name || "—"}</td>
<td class="col-status">
<span
class="sales-status-badge"
class:status-closed={opp.closedFlag}
class:status-open={!opp.closedFlag}
>
{statusLabel(opp)}
</span>
</td>
<td class="col-priority">
<span class="sales-priority">
{priorityLabel(opp)}
</span>
</td>
<td class="col-owner">{ownerLabel(opp)}</td>
<td class="col-close">
{formatDate(opp.expectedCloseDate)}
</td>
<td class="col-updated">
{formatDate(opp.cwLastUpdated || opp.updatedAt)}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
{#if totalPages > 1}
<div class="sales-footer">
<span class="sales-page-info">
Page {currentPage} of {totalPages}
</span>
<nav class="sales-pagination" aria-label="Sales pagination">
<button
class="sales-page-btn"
disabled={currentPage <= 1}
on:click={() => navigateToPage(currentPage - 1)}
aria-label="Previous page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{#each pageNumbers as p}
{#if p === "..."}
<span class="sales-page-ellipsis"></span>
{:else}
<button
class="sales-page-btn"
class:active={p === currentPage}
on:click={() => navigateToPage(p)}
aria-current={p === currentPage ? "page" : undefined}
>
{p}
</button>
{/if}
{/each}
<button
class="sales-page-btn"
disabled={currentPage >= totalPages}
on:click={() => navigateToPage(currentPage + 1)}
aria-label="Next page"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</nav>
</div>
{/if}
</div>
</div>
{/if}
<style>
.sales-access-denied {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.sales-access-denied svg {
width: 40px;
height: 40px;
color: var(--status-inactive-color);
}
.sales-access-denied h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.sales-access-denied p {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
text-align: center;
max-width: 360px;
}
</style>
+1 -5
View File
@@ -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 */
+115
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+468
View File
@@ -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;
}
}