feat: add procurement and sales sections
This commit is contained in:
@@ -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
|
## Features
|
||||||
libraries like [Shadcn-Svelte](https://next.shadcn-svelte.com/).
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
- **Authentication** — OAuth-based login flow via the Optima API
|
||||||
> This template uses SvelteKit's [hash router](https://svelte.dev/docs/kit/configuration#router) to
|
- **Company Management** — Browse and manage companies and linked Unifi sites
|
||||||
> create a single-page app. The only difference you'll have to look out for is to start all your routed
|
- **Credential Management** — Create, view, and organize credentials by type
|
||||||
> links with `#/` instead of `/`.
|
- **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/)
|
| Layer | Technology |
|
||||||
- [Electron](https://www.electronjs.org/)
|
| --------------- | ----------------------------------------------------------------------------------------- |
|
||||||
- [Electron Forge](https://www.electronforge.io/)
|
| Framework | [SvelteKit](https://kit.svelte.dev/) |
|
||||||
- [TypeScript](https://www.typescriptlang.org/)
|
| Language | [TypeScript](https://www.typescriptlang.org/) |
|
||||||
- [TailwindCSS](https://tailwindcss.com/)
|
| Styling | [Tailwind CSS v4](https://tailwindcss.com/) |
|
||||||
|
| Desktop | [Electron](https://www.electronjs.org/) + [Electron Forge](https://www.electronforge.io/) |
|
||||||
> [!NOTE]
|
| API Client | [Axios](https://axios-http.com/) |
|
||||||
> I've included TailwindCSS in this template because I use it in my own projects, but you can remove
|
| Realtime | [Socket.IO](https://socket.io/) |
|
||||||
> it easily if you don't want it.
|
| Testing | [Vitest](https://vitest.dev/) + [Playwright](https://playwright.dev/) |
|
||||||
|
| Package Manager | [Bun](https://bun.sh/) |
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
> [!WARNING]
|
### Prerequisites
|
||||||
> 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.
|
|
||||||
|
|
||||||
Start by installing the dependencies:
|
- [Bun](https://bun.sh/) (package manager & runtime)
|
||||||
|
- [Node.js 22+](https://nodejs.org/) (for Electron)
|
||||||
|
|
||||||
```
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
bun install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development:**
|
> **Note:** This project patches `@sveltejs/kit` via `patches/` to work around routing issues.
|
||||||
|
|
||||||
```
|
### Development (Desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
bun start
|
bun start
|
||||||
```
|
```
|
||||||
|
|
||||||
[Electron Forge](https://www.electronforge.io/) with the [Vite plugin](https://www.electronforge.io/plugins/vite)
|
Launches Electron with Vite HMR. The app opens to the login page and connects to the Optima API.
|
||||||
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).
|
|
||||||
|
|
||||||
**Production:**
|
### Development (Web Server)
|
||||||
|
|
||||||
```
|
```bash
|
||||||
bun run package
|
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
|
Builds and runs the SvelteKit server with `adapter-node` on port 3000.
|
||||||
app by opening the `.app` file in the `out` directory. This will not create your app's installer
|
|
||||||
for distribution though.
|
|
||||||
|
|
||||||
To create a distributable installer, you can use:
|
## Building
|
||||||
|
|
||||||
```
|
### Desktop — macOS
|
||||||
bun run make
|
|
||||||
|
```bash
|
||||||
|
bun run make:macos
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a distributable installer for your app. You can configure this in the `makers` section
|
Outputs `.dmg` and `.zip` to `out/make/`.
|
||||||
in `forge.config.ts`. Reference the [makers documentation](https://www.electronforge.io/makers) for more
|
|
||||||
information.
|
|
||||||
|
|
||||||
# Electron Crash Course
|
### Desktop — Windows
|
||||||
|
|
||||||
> [!NOTE]
|
```bash
|
||||||
> This is a super simplified version of the Electron documentation meant to give you a general idea
|
npm run make -- --platform win32
|
||||||
> 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ spec:
|
|||||||
- name: optima-ui
|
- name: optima-ui
|
||||||
image: ghcr.io/project-optima/ttscm-ui:latest
|
image: ghcr.io/project-optima/ttscm-ui:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
env:
|
env:
|
||||||
- name: PUBLIC_API_URL
|
- name: PUBLIC_API_URL
|
||||||
value: "https://opt-api.osdci.net"
|
value: "https://opt-api.osdci.net"
|
||||||
@@ -26,5 +33,17 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /login
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 15
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /login
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
- name: github-container-registry
|
- name: github-container-registry
|
||||||
|
|||||||
+125
-3
@@ -1,8 +1,118 @@
|
|||||||
// src/hooks.server.ts
|
// src/hooks.server.ts
|
||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
|
import { isInvalidSignatureError } from "$lib/optima-api/errorHandler";
|
||||||
import { redirect, type Handle } from "@sveltejs/kit";
|
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 }) => {
|
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 accessToken = event.cookies.get("accessToken") || null;
|
||||||
const refreshToken = event.cookies.get("refreshToken") || null;
|
const refreshToken = event.cookies.get("refreshToken") || null;
|
||||||
|
|
||||||
@@ -52,7 +162,13 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return redirect(303, "/login");
|
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
|
// Token is malformed or refresh failed — try refresh as fallback
|
||||||
if (currentRefreshToken) {
|
if (currentRefreshToken) {
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +176,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
await optima.user.refreshSession(currentRefreshToken);
|
await optima.user.refreshSession(currentRefreshToken);
|
||||||
currentAccessToken = refreshed.accessToken;
|
currentAccessToken = refreshed.accessToken;
|
||||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||||
} catch {
|
} catch (refreshErr) {
|
||||||
|
if (isInvalidSignatureError(refreshErr)) {
|
||||||
|
console.warn("Invalid refresh token signature — forcing logout.");
|
||||||
|
}
|
||||||
// Refresh also failed, force re-login
|
// Refresh also failed, force re-login
|
||||||
optima.user.logout(event);
|
optima.user.logout(event);
|
||||||
return redirect(303, "/login");
|
return redirect(303, "/login");
|
||||||
@@ -76,7 +195,10 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const refreshed = await optima.user.refreshSession(currentRefreshToken);
|
const refreshed = await optima.user.refreshSession(currentRefreshToken);
|
||||||
currentAccessToken = refreshed.accessToken;
|
currentAccessToken = refreshed.accessToken;
|
||||||
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
currentRefreshToken = refreshed.refreshToken ?? currentRefreshToken;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (isInvalidSignatureError(err)) {
|
||||||
|
console.warn("Invalid refresh token signature — forcing logout.");
|
||||||
|
}
|
||||||
optima.user.logout(event);
|
optima.user.logout(event);
|
||||||
return redirect(303, "/login");
|
return redirect(303, "/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { permission } from "./optima-api/modules/permissions";
|
|||||||
import { user } from "./optima-api/modules/user";
|
import { user } from "./optima-api/modules/user";
|
||||||
import { users } from "./optima-api/modules/users";
|
import { users } from "./optima-api/modules/users";
|
||||||
import { unifi } from "./optima-api/modules/unifi";
|
import { unifi } from "./optima-api/modules/unifi";
|
||||||
|
import { procurement } from "./optima-api/modules/procurement";
|
||||||
|
import { sales } from "./optima-api/modules/sales";
|
||||||
|
|
||||||
export const optima = {
|
export const optima = {
|
||||||
auth,
|
auth,
|
||||||
@@ -20,6 +22,8 @@ export const optima = {
|
|||||||
user,
|
user,
|
||||||
users,
|
users,
|
||||||
unifi,
|
unifi,
|
||||||
|
procurement,
|
||||||
|
sales,
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @TODO
|
* @TODO
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { error } from "@sveltejs/kit";
|
import { error, redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(
|
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 {
|
export function handleApiError(err: unknown): never {
|
||||||
console.error("API Error:", err);
|
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) {
|
if (err instanceof ApiError) {
|
||||||
throw error(err.statusCode, {
|
throw error(err.statusCode, {
|
||||||
message: err.message,
|
message: err.message,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,6 +11,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
|
|
||||||
let canViewAdmin = false;
|
let canViewAdmin = false;
|
||||||
try {
|
try {
|
||||||
|
const userInfo = await optima.user.fetchInfo(accessToken);
|
||||||
|
console.log("@me response:", JSON.stringify(userInfo, null, 2));
|
||||||
|
|
||||||
const permResult = await optima.user.checkPermissions(accessToken, [
|
const permResult = await optima.user.checkPermissions(accessToken, [
|
||||||
"ui.navigation.admin.view",
|
"ui.navigation.admin.view",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
import { theme } from "$lib/theme";
|
import { theme } from "$lib/theme";
|
||||||
|
import LoadingSpinner from "../components/LoadingSpinner.svelte";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@@ -15,6 +17,16 @@
|
|||||||
label: "Companies",
|
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>',
|
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 = {
|
const adminNavItem = {
|
||||||
@@ -33,6 +45,7 @@
|
|||||||
{#if $page.route.id?.startsWith("/(auth)")}
|
{#if $page.route.id?.startsWith("/(auth)")}
|
||||||
<slot />
|
<slot />
|
||||||
{:else}
|
{:else}
|
||||||
|
<LoadingSpinner loading={!!$navigating} />
|
||||||
<div class="layout-container">
|
<div class="layout-container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
|
|||||||
@@ -4,31 +4,37 @@ import { checkPermissions, type PermissionMap } from "$lib/permissions";
|
|||||||
import { redirect } from "@sveltejs/kit";
|
import { redirect } from "@sveltejs/kit";
|
||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals, parent }) => {
|
||||||
const accessToken = locals.session?.accessToken;
|
const accessToken = locals.session?.accessToken;
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw redirect(303, "/login");
|
throw redirect(303, "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check the top-level admin gate + all per-tab permissions in one call
|
// Grab the root layout data to reuse the admin-view permission it already checked
|
||||||
const permissions = await checkPermissions(accessToken, [
|
const parentData = await parent();
|
||||||
"ui.navigation.admin.view",
|
|
||||||
"admin.users.view",
|
|
||||||
"admin.roles.view",
|
|
||||||
"admin.credential-types.view",
|
|
||||||
]);
|
|
||||||
|
|
||||||
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, "/");
|
throw redirect(303, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch current user info for the dashboard greeting
|
// Fetch sub-tab permissions and user info in parallel
|
||||||
const userInfo = await optima.user.fetchInfo(accessToken);
|
const [permissions, userInfo] = await Promise.all([
|
||||||
|
checkPermissions(accessToken, [
|
||||||
|
"admin.users.view",
|
||||||
|
"admin.roles.view",
|
||||||
|
"admin.credential-types.view",
|
||||||
|
]),
|
||||||
|
optima.user.fetchInfo(accessToken),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: userInfo?.data ?? null,
|
user: userInfo?.data ?? null,
|
||||||
permissions,
|
permissions: {
|
||||||
|
"ui.navigation.admin.view": true, // Already verified via parent
|
||||||
|
...permissions,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-throw redirects so SvelteKit handles them
|
// Re-throw redirects so SvelteKit handles them
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
let isSearching = false;
|
let isSearching = false;
|
||||||
let searchInputEl: HTMLInputElement;
|
let searchInputEl: HTMLInputElement;
|
||||||
let searchStartedAt = 0;
|
let searchStartedAt = 0;
|
||||||
|
let isUserTyping = false;
|
||||||
|
|
||||||
// When navigation completes (results loaded), clear loading & refocus
|
// When navigation completes (results loaded), clear loading & refocus
|
||||||
// Ensure spinner stays visible for at least 500ms
|
// Ensure spinner stays visible for at least 500ms
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
const remaining = Math.max(0, 500 - elapsed);
|
const remaining = Math.max(0, 500 - elapsed);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
|
isUserTyping = false;
|
||||||
if (searchInputEl && document.activeElement !== searchInputEl) {
|
if (searchInputEl && document.activeElement !== searchInputEl) {
|
||||||
requestAnimationFrame(() => searchInputEl?.focus());
|
requestAnimationFrame(() => searchInputEl?.focus());
|
||||||
}
|
}
|
||||||
@@ -53,30 +55,41 @@
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", String(p));
|
params.set("page", String(p));
|
||||||
if (searchInput) params.set("search", searchInput);
|
if (searchInput) params.set("search", searchInput);
|
||||||
goto(`/companies?${params.toString()}`);
|
goto(`/companies?${params.toString()}`, { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
isSearching = true;
|
isUserTyping = true;
|
||||||
searchStartedAt = Date.now();
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(() => {
|
||||||
|
isSearching = true;
|
||||||
|
isUserTyping = false;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
if (searchInput) params.set("search", searchInput);
|
if (searchInput) params.set("search", searchInput);
|
||||||
goto(`/companies?${params.toString()}`);
|
goto(`/companies?${params.toString()}`, {
|
||||||
}, 300);
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
|
isUserTyping = false;
|
||||||
searchStartedAt = Date.now();
|
searchStartedAt = Date.now();
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
if (searchInput) params.set("search", searchInput);
|
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 -->
|
<!-- Pane body -->
|
||||||
<div class="pane-body">
|
<div class="pane-body">
|
||||||
{#if isSearching}
|
{#if isSearching && !isUserTyping}
|
||||||
<div class="search-loading-overlay">
|
<div class="search-loading-overlay">
|
||||||
<div class="search-spinner"></div>
|
<div class="search-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { optima } from "$lib";
|
import { optima } from "$lib";
|
||||||
import { handleApiError } from "$lib/optima-api/errorHandler";
|
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";
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
@@ -18,40 +18,41 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Run permission checks in parallel with other data fetches.
|
// Permissions are resolved locally from the Set populated in hooks — no API call
|
||||||
// Add any new permissions the company page needs to this array.
|
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 [
|
const [
|
||||||
permissions,
|
companyResult,
|
||||||
configsResult,
|
configsResult,
|
||||||
credentialsResult,
|
credentialsResult,
|
||||||
credentialTypesResult,
|
credentialTypesResult,
|
||||||
unifiSitesResult,
|
unifiSitesResult,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
checkPermissions(accessToken, [
|
optima.company.fetch(accessToken, params.id, {
|
||||||
"company.fetch.address",
|
includeAddress: permissions["company.fetch.address"] === true,
|
||||||
"company.fetch.contacts",
|
includePrimaryContact: true,
|
||||||
"credential.secure_values.read",
|
includeAllContacts: permissions["company.fetch.contacts"] === true,
|
||||||
"unifi.site.wifi",
|
}),
|
||||||
"unifi.site.wifi.read.name",
|
|
||||||
"unifi.site.wifi.update",
|
|
||||||
]),
|
|
||||||
optima.company.fetchConfigurations(accessToken, params.id),
|
optima.company.fetchConfigurations(accessToken, params.id),
|
||||||
optima.credential
|
optima.credential
|
||||||
.fetchByCompany(accessToken, params.id)
|
.fetchByCompany(accessToken, params.id)
|
||||||
.catch(() => ({ data: [] })),
|
.catch(() => ({ data: [] })),
|
||||||
optima.credentialType.fetchMany(accessToken).catch(() => ({ data: [] })),
|
optima.credentialType
|
||||||
|
.fetchMany(accessToken)
|
||||||
|
.catch(() => ({ data: [] })),
|
||||||
optima.unifi
|
optima.unifi
|
||||||
.fetchCompanySites(accessToken, params.id)
|
.fetchCompanySites(accessToken, params.id)
|
||||||
.catch(() => ({ data: [] })),
|
.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 {
|
return {
|
||||||
company: companyResult?.data ?? null,
|
company: companyResult?.data ?? null,
|
||||||
configurations: configsResult?.data ?? [],
|
configurations: configsResult?.data ?? [],
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 72px;
|
width: 90px;
|
||||||
background-color: var(--bg-surface-alt);
|
background-color: var(--bg-surface-alt);
|
||||||
border-right: 1px solid var(--border-default);
|
border-right: 1px solid var(--border-default);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -121,10 +121,6 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main Content */
|
/* Main Content */
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user