Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
133
.stitch/DESIGN.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Design System: BellSystems Console Design
|
||||||
|
**Project ID:** 18406618574074411899
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
**"The Digital Observatory"** — A high-fidelity, immersive enterprise command center aesthetic that rejects the typical boxed-in SaaS feel. The mood is best described as **Atmospheric Depth**: dark, spacious, and precision-focused. Inspired by high-end editorial design and command center interfaces, data is treated as a premium asset surfaced through tonal layering rather than structural lines.
|
||||||
|
|
||||||
|
The base is built on **Midnight Navy** — a deep blue-black that evokes an infinite canvas — with UI elements that appear to float and glow rather than sit flat. Hierarchy is established exclusively through surface tone shifts; hard borders are forbidden. The result feels like peering through a high-resolution window into enterprise data, where clarity comes from contrast in depth rather than weight.
|
||||||
|
|
||||||
|
Overall density is **medium-high** — information-rich layouts with generous vertical whitespace between elements, but no wasted screen real estate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Core Surfaces (darkest to lightest)
|
||||||
|
| Name | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| **Abyss** | `#0a0e14` | Deepest well; nested content backgrounds |
|
||||||
|
| **Midnight** | `#10141a` | Main application viewport / page background |
|
||||||
|
| **Void Navy** | `#181c22` | Sidebar, header, and secondary navigation surfaces |
|
||||||
|
| **Deep Slate** | `#1c2026` | Default card and information module background |
|
||||||
|
| **Elevated Slate** | `#262a31` | High cards, hovered table rows |
|
||||||
|
| **Island** | `#31353c` | Active states, selected rows, top-layer containers |
|
||||||
|
| **Frosted Glass** | `#353940` | Floating modals and dropdowns (at 80% opacity with blur) |
|
||||||
|
|
||||||
|
### Accent & Brand Colors
|
||||||
|
| Name | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| **Indigo Glow** | `#c0c1ff` | Primary accent; CTA text, key metrics, active nav indicator |
|
||||||
|
| **Lavender Soft** | `#8083ff` | Primary container fills |
|
||||||
|
| **Violet Pulse** | `#d2bbff` | Secondary accent; gradient pair to Indigo Glow |
|
||||||
|
| **Deep Indigo** | `#6001d1` | Secondary container fills |
|
||||||
|
| **Royal Indigo** | `#494bd6` | Inverse primary; used on light-over-dark contexts |
|
||||||
|
| **Aqua Sky** | `#7bd0ff` | Tertiary accent; data visualization, device status indicators |
|
||||||
|
| **Ocean** | `#009bd1` | Tertiary container |
|
||||||
|
|
||||||
|
### Semantic / State Colors
|
||||||
|
| Name | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| **Coral Error** | `#ffb4ab` | Error text and destructive action labels |
|
||||||
|
| **Crimson** | `#93000a` | Error container backgrounds |
|
||||||
|
| **Emerald** (Tailwind) | `emerald-*` | Online / active device status badges |
|
||||||
|
| **Amber** (Tailwind) | `amber-*` | Warning / pending device status badges |
|
||||||
|
|
||||||
|
### Text Colors
|
||||||
|
| Name | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| **Cloud** | `#dfe2eb` | Primary text; body copy, data values, headings |
|
||||||
|
| **Mist** | `#c7c4d7` | Secondary / muted text; labels, metadata |
|
||||||
|
| **Ghost** | `#908fa0` | Placeholder text, disabled states, divider fill |
|
||||||
|
| **Boundary** | `#464554` | Ghost border fallback for inputs (used at low opacity) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
**Single font family throughout: Inter** (geometric sans-serif). Used for headlines, body, and labels alike — unity is achieved through weight and tracking variation rather than font switching.
|
||||||
|
|
||||||
|
| Level | Size | Weight | Tracking | Usage |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Display** | 56px / 3.5rem | Bold (700) | Tight (negative) | Hero KPI metrics, total counts |
|
||||||
|
| **Headline** | 24px / 1.5rem | SemiBold (600) | Normal | Page titles, major section headings |
|
||||||
|
| **Title** | 16px / 1.0rem | Medium (500) | Normal | Card titles, module headers, tab labels |
|
||||||
|
| **Body** | 14px / 0.875rem | Regular (400) | Normal | Default text, table rows, descriptions |
|
||||||
|
| **Label** | 11px / 0.6875rem | SemiBold (600) | Wide (+0.1em) | Sidebar category headers, metadata chips — All Caps |
|
||||||
|
|
||||||
|
**The Editorial Rule:** Sidebar category headers use the Label style in all-caps with wide letter-spacing (+0.1em). This creates a "system-level" authority that contrasts with fluid body text and signals structural navigation. Body text must never be all-caps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
- **Primary CTA:** Gradient fill from Indigo Glow (`#c0c1ff`) to Violet Pulse (`#d2bbff`). White text. Softly rounded corners (matching `lg` radius — just barely rounded, not pill). On hover, a high-glow indigo shadow emanates beneath the button.
|
||||||
|
- **Secondary / Outline:** Island background (`#31353c`) with an extremely faint ghost border — outline-variant (`#464554`) at 20–30% opacity. Mist text (`#c7c4d7`).
|
||||||
|
- **Tertiary / Ghost:** Fully transparent background. Mist text. On hover: Cloud text with elevated background tint.
|
||||||
|
- **Destructive:** Coral Error text (`#ffb4ab`) on a Crimson container (`#93000a` at low opacity). Used for delete and irreversible actions. Never bright red.
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
- **Default Card:** Deep Slate background (`#1c2026`). No visible border — separation is purely tonal. Features a **subtle top-edge inner glow**: `inset 0px 1px 0px rgba(192, 193, 255, 0.05)` — mimics a ceiling light reflecting off the card's glass surface.
|
||||||
|
- **Corner Rounding:** Minimal — just enough to soften, not enough to feel playful. Approximately 4px (the `lg` token = 0.25rem). Cards feel rectangular and purposeful, not bubbly.
|
||||||
|
- **Elevation principle:** No drop shadows on standard cards. Hierarchy comes from the surface color step (Void Navy → Deep Slate → Elevated Slate).
|
||||||
|
- **Floating Modals / Dropdowns:** Frosted Glass background (`rgba(53, 57, 64, 0.8)`) with `backdrop-filter: blur(12px)`. This "Glassmorphism" effect keeps the user contextually anchored to the underlying page while the modal floats above. Shadow: `0px 8px 24px rgba(13, 17, 23, 0.6)` — navy-tinted, never pure black.
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
- **Default state:** Deep Slate background. Ghost border at very low opacity (near invisible). Text in Cloud color.
|
||||||
|
- **Focus state:** Indigo Glow (`#c0c1ff`) border at 40% alpha creates a soft halo glow — not a hard ring. The inner glow on the input also subtly strengthens.
|
||||||
|
- **Placeholder text:** Ghost color (`#908fa0`).
|
||||||
|
- **Select / Dropdown:** Same surface as inputs; opens as a Frosted Glass panel with blur.
|
||||||
|
- **No hard outlines at rest** — inputs feel embedded in the surface until interacted with.
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
- **Shape:** Fully pill-shaped (maximum border-radius / `full` = 0.75rem).
|
||||||
|
- **Style:** Functional color (Emerald, Amber, Coral) at approximately 15% background opacity, with the same color at full 100% opacity for the label text. Creates a "glowing ink" effect — the badge appears illuminated from within.
|
||||||
|
- **Examples:** Online → soft emerald glow; Warning → soft amber glow; Error/Offline → soft coral glow.
|
||||||
|
|
||||||
|
### Navigation Sidebar (224px wide)
|
||||||
|
- **Background:** Void Navy (`#181c22`) — one step lighter than the main viewport.
|
||||||
|
- **Active item indicator:** A `3px` vertical bar on the far-left edge using Indigo Glow (`#c0c1ff`). The text weight also increases slightly. No background highlight on active items — the light bar IS the indicator.
|
||||||
|
- **Inactive items:** Mist text (`#c7c4d7`) at normal weight.
|
||||||
|
- **Category headers:** Label-style — all-caps, 11px, SemiBold, wide tracking. Ghost color (`#908fa0`).
|
||||||
|
- **Item padding:** Comfortable vertical padding (~9.6px / 0.6rem) for breathing room between items.
|
||||||
|
- **No dividers** between nav sections — spacing does the work.
|
||||||
|
|
||||||
|
### Data Tables
|
||||||
|
- **Row separation:** No horizontal dividers. Alternating subtle tonal rows (Island `#31353c` on hover) and consistent vertical gap rhythm.
|
||||||
|
- **Header row:** Mist text (`#c7c4d7`), Label-style capitalization, slightly smaller than body.
|
||||||
|
- **Hovered row:** Elevated Slate (`#262a31`) or Island (`#31353c`) background.
|
||||||
|
- **Selected row:** Island background with Indigo Glow left border accent.
|
||||||
|
|
||||||
|
### Scrollbars
|
||||||
|
- Slim — 4px wide track.
|
||||||
|
- Thumb: Boundary color (`#464554`), with 2px border-radius.
|
||||||
|
- Track: Transparent.
|
||||||
|
- Overall feel: Nearly invisible unless sought out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
**Whitespace is structure.** The design never uses lines or dividers to separate sections — space does that job. When content feels disconnected, the solution is always to add vertical breathing room, never to draw a border.
|
||||||
|
|
||||||
|
- **Page content area:** Fills the viewport to the right of the 224px sidebar. Padding inside the content area is generous — approximately 24–32px on all sides.
|
||||||
|
- **Section spacing:** Major sections within a page are separated by approximately 24px of vertical space. Sub-sections by 16px.
|
||||||
|
- **Card grid:** Cards sit in fluid grids with 16px gaps. Cards never touch each other.
|
||||||
|
- **Alignment:** Strong left-edge alignment for all content. Data tables, card headers, and page titles all share the same left origin point.
|
||||||
|
- **No horizontal rules / `<hr>` elements:** Surface color transitions and whitespace define the visual structure entirely.
|
||||||
|
- **Modals:** Centered in the viewport, overlaid on a dark scrim. The page content behind is still readable through the frosted glass effect, maintaining spatial context.
|
||||||
|
- **The "No Raw Border" rule:** Any element requiring a visible boundary for accessibility (e.g., an active input) must use the ghost border approach — Boundary color (`#464554`) at 20% opacity maximum. Full-opacity borders are strictly prohibited.
|
||||||
|
- **Mobile / responsive:** The sidebar collapses to a drawer on narrow viewports. Cards reflow to single-column. The design's depth relies on background layers, so it translates naturally to smaller screens.
|
||||||
383
CLAUDE.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# CLAUDE.md — BellSystems Control Panel v2
|
||||||
|
# Instructions for Claude Code
|
||||||
|
|
||||||
|
> Read this file at the start of every session.
|
||||||
|
> This is the v2 project — a clean rebuild. The old v1 code lives in `frontend/src/_archive/` for reference only.
|
||||||
|
> Also read `DESIGN.md` before writing any UI code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\development\bellsystems-cp-v2\
|
||||||
|
│ CLAUDE.md ← you are here
|
||||||
|
│ DESIGN.md ← design rules, component contracts, page layout spec
|
||||||
|
│ docker-compose.yml
|
||||||
|
│
|
||||||
|
├── backend/ ← FastAPI backend — DO NOT MODIFY
|
||||||
|
│
|
||||||
|
└── frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── _archive/ ← v1 reference code — READ ONLY, never import from here except auth
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ ├── global-icons/ ← action SVGs (edit, delete, download, etc.)
|
||||||
|
│ │ ├── side-menu-icons/ ← sidebar navigation SVGs
|
||||||
|
│ │ ├── comms/ ← communication type SVGs
|
||||||
|
│ │ ├── other-icons/ ← misc SVGs
|
||||||
|
│ │ └── customer-status/ ← CRM status SVGs
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ ← design system components (the ONLY place to source UI)
|
||||||
|
│ │ ├── layout/ ← Sidebar, Header, MainLayout
|
||||||
|
│ │ └── shared/
|
||||||
|
│ ├── hooks/
|
||||||
|
│ ├── lib/
|
||||||
|
│ ├── modals/ ← all modal components live here, grouped by domain
|
||||||
|
│ ├── pages/ ← one file per page, grouped by domain
|
||||||
|
│ ├── providers/
|
||||||
|
│ ├── router/
|
||||||
|
│ │ └── index.jsx ← all routes defined here
|
||||||
|
│ ├── styles/
|
||||||
|
│ │ ├── tokens.css ← ALL design tokens (colors, fonts, spacing, shadows)
|
||||||
|
│ │ ├── components.css ← ALL component-level styles
|
||||||
|
│ │ └── global.css ← base resets, typography, scrollbar, .page-wrapper
|
||||||
|
│ └── main.jsx ← app entry point
|
||||||
|
└── vite.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Bespoke SaaS Admin Console for BellSystems. Manages Devices, Customers (CRM),
|
||||||
|
Manufacturing, Firmware, MQTT, Melodies, Staff, and more.
|
||||||
|
|
||||||
|
- **Backend:** FastAPI at `backend/` — never modify
|
||||||
|
- **Archive:** `frontend/src/_archive/` — v1 reference, read-only
|
||||||
|
- **Active code:** `frontend/src/` (everything except `_archive/`)
|
||||||
|
- **Design rules:** `DESIGN.md` at project root — read before writing any UI
|
||||||
|
- **Style Guide:** live at `/dev/styleguide` — shows every component with every variant
|
||||||
|
- **API client:** `frontend/src/lib/api.js` wraps `_archive/api/client.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Import Alias
|
||||||
|
|
||||||
|
`@/` maps to `frontend/src/`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import Button from '@/components/ui/Button' ✅
|
||||||
|
import Select from '@/components/ui/Select' ✅
|
||||||
|
import { useAuth } from '@/hooks/useAuth' ✅
|
||||||
|
import MainLayout from '@/components/layout/MainLayout' ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use relative `../` paths except inside `providers/` and `hooks/` when referencing `_archive/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Golden Rules
|
||||||
|
|
||||||
|
- **Never modify `_archive/`** — it is read-only reference material
|
||||||
|
- **All new code goes in `frontend/src/`** — no exceptions
|
||||||
|
- **No `/v2/` prefix anywhere** — routes start from `/`, imports start from `@/`
|
||||||
|
- **Read `DESIGN.md` before writing any UI code**
|
||||||
|
- **Source every UI element from `@/components/ui/`** — no raw HTML elements for styled things
|
||||||
|
- **Use only CSS tokens** — never raw hex, rgb, or pixel values in component or page files
|
||||||
|
- **Use `.masonry-grid` for all content pages with multiple variable-height sections** — never `display: grid` with fixed columns for card layouts. See DESIGN.md §11.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available UI Components
|
||||||
|
|
||||||
|
Every component lives in `frontend/src/components/ui/`. These are the ONLY components to use.
|
||||||
|
Check the live Style Guide at `/dev/styleguide` to see all variants and states.
|
||||||
|
|
||||||
|
| Component | Import path | Purpose |
|
||||||
|
|-----------------|--------------------------------------|----------------------------------------------|
|
||||||
|
| `Button` | `@/components/ui/Button` | All interactive actions |
|
||||||
|
| `StatusBadge` | `@/components/ui/StatusBadge` | Coloured status pills |
|
||||||
|
| `FormField` | `@/components/ui/FormField` | Every text/email/password/textarea input |
|
||||||
|
| `Select` | `@/components/ui/Select` | Custom dropdown (used inside FormField type="select") |
|
||||||
|
| `Modal` | `@/components/ui/Modal` | All overlay dialogs |
|
||||||
|
| `ConfirmDialog` | `@/components/ui/ConfirmDialog` | Destructive / confirmation prompts |
|
||||||
|
| `DataTable` | `@/components/ui/DataTable` | All tabular data with sorting/selection |
|
||||||
|
| `Pagination` | `@/components/ui/Pagination` | Page controls beneath DataTable |
|
||||||
|
| `Spinner` | `@/components/ui/Spinner` | Loading indicators |
|
||||||
|
| `PageHeader` | `@/components/ui/PageHeader` | Page title block — every page starts with this |
|
||||||
|
| `Card` | `@/components/ui/Card` | Contained content sections |
|
||||||
|
| `Tabs` | `@/components/ui/Tabs` | Tabbed navigation within a page |
|
||||||
|
| `Toast` | `@/components/ui/Toast` | Transient notifications (via `useToast`) |
|
||||||
|
| `SearchBar` | `@/components/ui/SearchBar` | Search inputs with debounce |
|
||||||
|
| `Breadcrumbs` | `@/components/ui/Breadcrumbs` | Navigation trail on detail pages |
|
||||||
|
| `Icon` | `@/components/ui/Icon` | Inline SVG icons by name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Folder Structure — Pages & Modals
|
||||||
|
|
||||||
|
Folders mirror the sidebar section hierarchy exactly.
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/pages/
|
||||||
|
├── auth/ ← unauthenticated routes (login)
|
||||||
|
├── dashboard/ ← General section
|
||||||
|
├── bellcloud/ ← Bell Cloud section
|
||||||
|
│ ├── devices/
|
||||||
|
│ │ └── notes/
|
||||||
|
│ ├── users/
|
||||||
|
│ ├── melodies/
|
||||||
|
│ │ └── archetypes/
|
||||||
|
│ └── mqtt/
|
||||||
|
├── crm/ ← Headquarters section
|
||||||
|
│ ├── comms/
|
||||||
|
│ │ └── mail/
|
||||||
|
│ ├── customers/
|
||||||
|
│ │ └── tabs/
|
||||||
|
│ ├── orders/
|
||||||
|
│ ├── quotations/
|
||||||
|
│ └── products/
|
||||||
|
├── engineering/ ← Engineering section
|
||||||
|
│ ├── manufacturing/
|
||||||
|
│ ├── firmware/
|
||||||
|
│ └── developer/
|
||||||
|
├── public/ ← Public / unauthenticated pages
|
||||||
|
│ ├── cloudflash/
|
||||||
|
│ └── serial/
|
||||||
|
├── settings/ ← Console Settings section
|
||||||
|
│ └── staff/
|
||||||
|
└── dev/ ← Internal dev tools (StyleGuide)
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/modals/
|
||||||
|
├── bellcloud/
|
||||||
|
│ ├── devices/
|
||||||
|
│ ├── melodies/
|
||||||
|
│ └── users/
|
||||||
|
├── crm/
|
||||||
|
│ └── products/
|
||||||
|
├── engineering/
|
||||||
|
│ └── manufacturing/
|
||||||
|
└── shared/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Layout — How Every Page Is Structured
|
||||||
|
|
||||||
|
Every authenticated page is rendered inside `MainLayout`, which provides:
|
||||||
|
- **Sidebar** — fixed left, `224px` wide (`--sidebar-width`)
|
||||||
|
- **Header** — fixed top, `56px` tall (`--header-height`)
|
||||||
|
- **Content area** — the remaining viewport space
|
||||||
|
|
||||||
|
Inside the content area, every page uses the `.page-wrapper` class (defined in `global.css`):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.page-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-12); /* 48px on all sides — desktop */
|
||||||
|
gap: var(--space-6); /* 24px between top-level sections */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-wrapper {
|
||||||
|
padding: var(--space-8); /* 32px on mobile */
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This is the consistency guarantee**: because every page uses `.page-wrapper`, the `<PageHeader>` title on every page starts at exactly the same position — 48px from the top and 48px from the left edge of the content area. Never override these paddings. Never add extra wrappers around `page-wrapper` that introduce additional offset.
|
||||||
|
|
||||||
|
### Content width modes
|
||||||
|
|
||||||
|
Pages fall into two modes — choose based on how much content the page has:
|
||||||
|
|
||||||
|
**Full-width** (default — lists, tables, dashboards):
|
||||||
|
```jsx
|
||||||
|
<div className="page-wrapper">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Centered** (forms, settings, pages with very few items):
|
||||||
|
```jsx
|
||||||
|
<div className="page-wrapper page-wrapper--centered">
|
||||||
|
```
|
||||||
|
Centered mode caps each direct child at `--content-max-width-sm` (640px) by default and centers it horizontally. Override when needed:
|
||||||
|
```jsx
|
||||||
|
<div className="page-wrapper page-wrapper--centered"
|
||||||
|
style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}>
|
||||||
|
```
|
||||||
|
Available tokens: `--content-max-width-xs` (480px), `--content-max-width-sm` (640px), `--content-max-width-md` (800px), `--content-max-width-lg` (1024px).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules for Every Page
|
||||||
|
|
||||||
|
### Before writing any code
|
||||||
|
1. Read `DESIGN.md` — confirm tokens and components to use
|
||||||
|
2. Check `frontend/src/components/ui/` — use existing components only
|
||||||
|
3. Check the Style Guide at `/dev/styleguide` for the correct variant/props
|
||||||
|
4. Check `frontend/src/_archive/` for the equivalent v1 page — copy API calls and data shape only, never styling
|
||||||
|
|
||||||
|
### Page template
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// frontend/src/pages/[domain]/PageName.jsx
|
||||||
|
|
||||||
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
// Import ONLY from @/components/ui/ — never raw HTML elements for styled things
|
||||||
|
|
||||||
|
export default function PageName() {
|
||||||
|
// 1. Auth
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
// 2. State & data fetching
|
||||||
|
|
||||||
|
// 3. Event handlers
|
||||||
|
|
||||||
|
// 4. Render — always handle: loading, error, empty, data states
|
||||||
|
return (
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<PageHeader title="Page Title" subtitle="Optional description">
|
||||||
|
{/* Action buttons — use <Button variant="primary"> etc. */}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Page content — use Card, DataTable, Tabs, etc. */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling rules
|
||||||
|
- **Tailwind** for layout only: `flex`, `grid`, `items-center`, `min-w-0`, etc.
|
||||||
|
- **CSS token variables** for ALL colors, spacing, typography — `var(--token-name)`
|
||||||
|
- **No** `.module.css` or per-page scoped CSS files
|
||||||
|
- **No** `style={{ }}` inline styles except for genuinely dynamic values (e.g. calculated widths)
|
||||||
|
- **No** raw hex, rgb, or pixel values anywhere
|
||||||
|
|
||||||
|
### Toolbar buttons — matching SearchBar height
|
||||||
|
|
||||||
|
`.btn` has `line-height: 1` while `.searchbar-input` has `line-height: var(--line-height-base)` (1.5). This makes buttons shorter than the search bar by default. Whenever a `<Button>`, `<SegmentedControl>`, or `<IconButtonGroup>` sits in the same toolbar row as a `<SearchBar>`, all buttons must use `padding-top/bottom: var(--space-3)` and `line-height: var(--line-height-base)`.
|
||||||
|
|
||||||
|
**Already handled automatically (no extra props needed):**
|
||||||
|
- `SegmentedControl` — `.seg-ctrl__btn` in `components.css` enforces `--space-3` padding and `var(--line-height-base)` globally.
|
||||||
|
- `IconButtonGroup` — `.icon-btn-group__btn` in `components.css` enforces `--space-3` padding globally.
|
||||||
|
|
||||||
|
**Must be overridden manually — standalone `<Button>` in the same row as a `<SearchBar>`:**
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// Option A — inline style prop on the button:
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
style={{ paddingTop: 'var(--space-3)', paddingBottom: 'var(--space-3)', lineHeight: 'var(--line-height-base)' }}
|
||||||
|
>
|
||||||
|
Label
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Option B (preferred when multiple buttons share a toolbar) — scoped CSS block:
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.my-toolbar .btn {
|
||||||
|
padding-top: var(--space-3) !important;
|
||||||
|
padding-bottom: var(--space-3) !important;
|
||||||
|
line-height: var(--line-height-base) !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="my-toolbar" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
|
<IconButtonGroup … />
|
||||||
|
<Button variant="primary" size="md">Compose</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date & time formatting
|
||||||
|
- **Greek/European date style everywhere** — DD/MM/YYYY, never US-style MM/DD/YYYY
|
||||||
|
- **All date/time formatting must use `@/lib/formatters`** — never use raw `toLocaleDateString()`, `Intl.DateTimeFormat`, `toLocaleString()`, or `toISOString().slice()` in pages or modals
|
||||||
|
- **For `datetime-local` input values** — use `toDatetimeLocal(iso)` and `nowLocal()` from formatters. Never use `new Date(x).toISOString().slice(0, 16)` — it converts to UTC and shifts the time (timezone bug)
|
||||||
|
- **Currency** — use `fmtEuro(n)` from formatters (Greek locale: `1.250,00 €`)
|
||||||
|
|
||||||
|
Available formatters (`import { ... } from '@/lib/formatters'`):
|
||||||
|
|
||||||
|
| Function | Output example | Use for |
|
||||||
|
|---------------------|------------------------------------|--------------------------------------|
|
||||||
|
| `fmtDate` | `05/03/2026` | Short numeric dates (tables, lists) |
|
||||||
|
| `fmtDateMedium` | `5 Mar 2026` | Medium dates (cards, details) |
|
||||||
|
| `fmtDateLong` | `5 March 2026` | Long dates (headings, summaries) |
|
||||||
|
| `fmtDateFull` | `Wednesday, 5 March 2026` | Dashboard, full context |
|
||||||
|
| `fmtDateTime` | `5 March 2026, 2:30 pm` | Date + 12h time |
|
||||||
|
| `fmtDateTimeMedium` | `5 Mar 2026, 14:30` | Date + 24h time (compact) |
|
||||||
|
| `fmtDateTimeFull` | `Wed, 5 Mar 2026, 2:30 pm` | Emails, comms |
|
||||||
|
| `fmtTime24` | `14:30:05` | Time with seconds |
|
||||||
|
| `fmtRelative` | `5 minutes ago` | Relative timestamps |
|
||||||
|
| `toDatetimeLocal` | `2026-03-05T14:30` | `datetime-local` input values |
|
||||||
|
| `nowLocal` | `2026-03-05T14:30` | Current time for form defaults |
|
||||||
|
| `toDateInput` | `2026-03-05` | `date` input values |
|
||||||
|
| `fmtEuro` | `1.250,00 €` | Euro currency |
|
||||||
|
|
||||||
|
### Data fetching
|
||||||
|
- Use `frontend/src/lib/api.js` (wraps `_archive/api/client.js`)
|
||||||
|
- Every data-fetching component must handle **loading**, **error**, and **empty** states
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
- Never defined inside page files
|
||||||
|
- Live in `frontend/src/modals/[sidebar-section]/[domain]/ModalName.jsx` — mirror the pages folder structure
|
||||||
|
- Pass data via props, actions via callbacks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Components — Stop and Ask
|
||||||
|
|
||||||
|
If building a page requires a UI component that does not exist in
|
||||||
|
frontend/src/components/ui/, Claude Code must STOP and say:
|
||||||
|
|
||||||
|
"I need a [ComponentName] component which doesn't exist yet.
|
||||||
|
Please build it and add it to the StyleGuide before I continue."
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- Invent an inline one-off component inside a page file
|
||||||
|
- Use raw HTML elements styled with inline CSS as a substitute
|
||||||
|
- Proceed and leave a placeholder
|
||||||
|
|
||||||
|
The StyleGuide at frontend/src/pages/dev/StyleGuide.jsx is the source
|
||||||
|
of truth for what components exist and how they look. Every component
|
||||||
|
used in a page must have a visible example there first.
|
||||||
|
|
||||||
|
## Building a New Page — Checklist
|
||||||
|
|
||||||
|
- [ ] File in correct `frontend/src/pages/[domain]/` folder
|
||||||
|
- [ ] Root element is `<div className="page-wrapper">` — nothing else, nothing wrapping it
|
||||||
|
- [ ] First child inside `page-wrapper` is `<PageHeader title="...">`
|
||||||
|
- [ ] Only components from `frontend/src/components/ui/` used
|
||||||
|
- [ ] No raw hex colors or pixel spacing values anywhere
|
||||||
|
- [ ] Loading state implemented
|
||||||
|
- [ ] Error state implemented
|
||||||
|
- [ ] Empty state implemented
|
||||||
|
- [ ] Mobile responsive (375px minimum)
|
||||||
|
- [ ] Modals in `frontend/src/modals/`
|
||||||
|
- [ ] Route added to `frontend/src/router/index.jsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
|
||||||
|
```js
|
||||||
|
// frontend/src/lib/api.js
|
||||||
|
export { default } from '../_archive/api/client'
|
||||||
|
```
|
||||||
|
|
||||||
|
All pages import from `@/lib/api`, never directly from `_archive`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
`frontend/src/hooks/useAuth.js` re-exports from the archive AuthContext.
|
||||||
|
`frontend/src/providers/AuthProvider.jsx` re-exports the AuthProvider.
|
||||||
|
|
||||||
|
These are the ONLY two files permitted to import from `_archive/auth/`.
|
||||||
|
All other files use `@/hooks/useAuth`.
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
# CRM Customer Status System — Implementation Plan
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
This project is a Vue/React + FastAPI + Firestore admin console located at `C:\development\bellsystems-cp`.
|
|
||||||
|
|
||||||
The frontend lives in `frontend/src/` and the backend in `backend/`.
|
|
||||||
The CRM module is at `frontend/src/crm/` and `backend/crm/`.
|
|
||||||
|
|
||||||
Currently, customers have two flat boolean flags on their Firestore document:
|
|
||||||
- `negotiating: bool`
|
|
||||||
- `has_problem: bool`
|
|
||||||
|
|
||||||
These need to be replaced with a richer, structured system as described below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Target Data Model
|
|
||||||
|
|
||||||
### 1A. On the Customer Document (`customers/{id}`)
|
|
||||||
|
|
||||||
Remove `negotiating` and `has_problem`. Add the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
relationship_status: string
|
|
||||||
— one of: "lead" | "prospect" | "active" | "inactive" | "churned"
|
|
||||||
— default: "lead"
|
|
||||||
|
|
||||||
technical_issues: array of {
|
|
||||||
active: bool,
|
|
||||||
opened_date: Firestore Timestamp,
|
|
||||||
resolved_date: Firestore Timestamp | null,
|
|
||||||
note: string,
|
|
||||||
opened_by: string, ← display name or user ID of staff member
|
|
||||||
resolved_by: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
install_support: array of {
|
|
||||||
active: bool,
|
|
||||||
opened_date: Firestore Timestamp,
|
|
||||||
resolved_date: Firestore Timestamp | null,
|
|
||||||
note: string,
|
|
||||||
opened_by: string,
|
|
||||||
resolved_by: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction_history: array of {
|
|
||||||
date: Firestore Timestamp,
|
|
||||||
flow: string, ← "invoice" | "payment" | "refund" | "credit"
|
|
||||||
payment_type: string | null, ← "cash" | "bank_transfer" | "card" | "paypal" — null for invoices
|
|
||||||
category: string, ← "full_payment" | "advance" | "installment"
|
|
||||||
amount: number,
|
|
||||||
currency: string, ← default "EUR"
|
|
||||||
invoice_ref: string | null,
|
|
||||||
order_ref: string | null, ← references an order document ID, nullable
|
|
||||||
recorded_by: string,
|
|
||||||
note: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1B. Orders Subcollection (`customers/{id}/orders/{order_id}`)
|
|
||||||
|
|
||||||
Orders live **exclusively** as a subcollection under each customer. There is no top-level `orders`
|
|
||||||
collection. The existing top-level `orders` collection in Firestore and its corresponding backend
|
|
||||||
routes should be **removed entirely** and replaced with subcollection-based routes under
|
|
||||||
`/crm/customers/{customer_id}/orders/`.
|
|
||||||
|
|
||||||
If cross-customer order querying is ever needed in the future, use Firestore's native
|
|
||||||
`collectionGroup("orders")` query — no top-level mirror collection is required.
|
|
||||||
|
|
||||||
Each order document carries the following fields:
|
|
||||||
|
|
||||||
```
|
|
||||||
order_number: string ← e.g. "ORD-2026-041" (already exists — keep)
|
|
||||||
title: string ← NEW: human-readable name e.g. "3x Wall Mount Units - Athens Office"
|
|
||||||
created_by: string ← NEW: staff user ID or display name
|
|
||||||
|
|
||||||
status: string ← REPLACE existing OrderStatus enum with new values:
|
|
||||||
— "negotiating" | "awaiting_quotation" | "awaiting_customer_confirmation"
|
|
||||||
| "awaiting_fulfilment" | "awaiting_payment" | "manufacturing"
|
|
||||||
| "shipped" | "installed" | "declined" | "complete"
|
|
||||||
|
|
||||||
status_updated_date: Firestore Timestamp ← NEW
|
|
||||||
status_updated_by: string ← NEW
|
|
||||||
|
|
||||||
payment_status: object { ← NEW — replaces the flat PaymentStatus enum
|
|
||||||
required_amount: number,
|
|
||||||
received_amount: number, ← computed from transaction_history where order_ref matches
|
|
||||||
balance_due: number, ← computed: required_amount - received_amount
|
|
||||||
advance_required: bool,
|
|
||||||
advance_amount: number | null,
|
|
||||||
payment_complete: bool
|
|
||||||
}
|
|
||||||
|
|
||||||
timeline: array of { ← NEW — order event log
|
|
||||||
date: Firestore Timestamp,
|
|
||||||
type: string, ← "quote_request" | "quote_sent" | "quote_accepted" | "quote_declined"
|
|
||||||
| "mfg_started" | "mfg_complete" | "order_shipped" | "installed"
|
|
||||||
| "payment_received" | "invoice_sent" | "note"
|
|
||||||
note: string,
|
|
||||||
updated_by: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Backend Changes
|
|
||||||
|
|
||||||
### 2A. `backend/crm/models.py`
|
|
||||||
|
|
||||||
- **Remove** `negotiating: bool` and `has_problem: bool` from `CustomerCreate` and `CustomerUpdate`.
|
|
||||||
- **Add** `relationship_status: Optional[str] = "lead"` to `CustomerCreate` and `CustomerUpdate`.
|
|
||||||
- **Add** `technical_issues: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
|
|
||||||
- **Add** `install_support: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
|
|
||||||
- **Add** `transaction_history: List[dict] = []` to `CustomerCreate` and `CustomerUpdate`.
|
|
||||||
- **Add** proper Pydantic models for each of the above array item shapes:
|
|
||||||
- `TechnicalIssue` model
|
|
||||||
- `InstallSupportEntry` model
|
|
||||||
- `TransactionEntry` model
|
|
||||||
- **Update** `OrderStatus` enum with the new values:
|
|
||||||
`negotiating`, `awaiting_quotation`, `awaiting_customer_confirmation`,
|
|
||||||
`awaiting_fulfilment`, `awaiting_payment`, `manufacturing`,
|
|
||||||
`shipped`, `installed`, `declined`, `complete`
|
|
||||||
- **Replace** the flat `PaymentStatus` enum on `OrderCreate` / `OrderUpdate` with a new `OrderPaymentStatus` Pydantic model matching the structure above.
|
|
||||||
- **Add** `title: Optional[str]`, `created_by: Optional[str]`, `status_updated_date: Optional[str]`,
|
|
||||||
`status_updated_by: Optional[str]`, and `timeline: List[dict] = []` to `OrderCreate` and `OrderUpdate`.
|
|
||||||
|
|
||||||
### 2B. `backend/crm/customers_router.py`
|
|
||||||
|
|
||||||
- Update any route that reads/writes `negotiating` or `has_problem` to use the new fields.
|
|
||||||
- Add new dedicated endpoints:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /crm/customers/{id}/technical-issues
|
|
||||||
— body: { note: str, opened_by: str }
|
|
||||||
— appends a new active issue to the array
|
|
||||||
|
|
||||||
PATCH /crm/customers/{id}/technical-issues/{index}/resolve
|
|
||||||
— body: { resolved_by: str }
|
|
||||||
— sets active=false and resolved_date=now on the item at that index
|
|
||||||
|
|
||||||
POST /crm/customers/{id}/install-support
|
|
||||||
— same pattern as technical-issues above
|
|
||||||
|
|
||||||
PATCH /crm/customers/{id}/install-support/{index}/resolve
|
|
||||||
— same as technical-issues resolve
|
|
||||||
|
|
||||||
POST /crm/customers/{id}/transactions
|
|
||||||
— body: TransactionEntry (see model above)
|
|
||||||
— appends to transaction_history
|
|
||||||
|
|
||||||
PATCH /crm/customers/{id}/relationship-status
|
|
||||||
— body: { status: str }
|
|
||||||
— updates relationship_status field
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2C. `backend/crm/orders_router.py`
|
|
||||||
|
|
||||||
- **Remove** all top-level `/crm/orders/` routes entirely.
|
|
||||||
- Re-implement all order CRUD under `/crm/customers/{customer_id}/orders/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /crm/customers/{customer_id}/orders/
|
|
||||||
POST /crm/customers/{customer_id}/orders/
|
|
||||||
GET /crm/customers/{customer_id}/orders/{order_id}
|
|
||||||
PATCH /crm/customers/{customer_id}/orders/{order_id}
|
|
||||||
DELETE /crm/customers/{customer_id}/orders/{order_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add endpoint to append a timeline event:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /crm/customers/{customer_id}/orders/{order_id}/timeline
|
|
||||||
— body: { type: str, note: str, updated_by: str }
|
|
||||||
— appends to the timeline array and updates status_updated_date + status_updated_by
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add endpoint to update payment status:
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /crm/customers/{customer_id}/orders/{order_id}/payment-status
|
|
||||||
— body: OrderPaymentStatus fields (partial update allowed)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Add a dedicated "Init Negotiations" endpoint:
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /crm/customers/{customer_id}/orders/init-negotiations
|
|
||||||
— body: { title: str, note: str, date: datetime, created_by: str }
|
|
||||||
— creates a new order with status="negotiating", auto-fills all other fields
|
|
||||||
— simultaneously updates the customer's relationship_status to "active"
|
|
||||||
(only if currently "lead" or "prospect" — do not downgrade an already "active" customer)
|
|
||||||
— returns the newly created order document
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Frontend Changes
|
|
||||||
|
|
||||||
### 3A. `frontend/src/crm/customers/CustomerList.jsx`
|
|
||||||
|
|
||||||
- When Notes: Quick filter is set, replace the `negotiating` and `has_problem` boolean badge display in the Status column with:
|
|
||||||
- A **relationship status chip** (color-coded pill: lead=grey, prospect=blue, active=green, inactive=amber, churned=soft red)
|
|
||||||
- A small **red dot / warning icon** if `technical_issues.some(i => i.active)` is true, under a new "Support" column. Add this column to the list of arrangeable and toggleable columns.
|
|
||||||
- A small **amber dot / support icon** if `install_support.some(i => i.active)` is true, under the same "Support" column.
|
|
||||||
- These are derived from the arrays — do not store a separate boolean on the document.
|
|
||||||
- When Notes: Expanded filter is set, replace the `negotiating` and `has_problem` verbose displays with the active order status (if any) in this format:
|
|
||||||
`"<Status Label> — <Date> — <Note>"` e.g. `"Negotiating — 24.03.26 — Customer requested a more affordable quotation"`
|
|
||||||
|
|
||||||
### 3B. `frontend/src/crm/customers/CustomerDetail.jsx`
|
|
||||||
|
|
||||||
The customer detail page currently has a tab structure: Overview, Orders, Quotations, Communication, Files & Media, Devices.
|
|
||||||
|
|
||||||
Make the following changes:
|
|
||||||
|
|
||||||
#### Whole page
|
|
||||||
- On the top of the page where we display the name, organization and full address, change it to:
|
|
||||||
Line 1: `Full Title + Name + Surname`
|
|
||||||
Line 2: `Organization · City` (city only, not full address)
|
|
||||||
- Remove the horizontal separation line after the title and before the tabs.
|
|
||||||
- On the top right side, there is an Edit Customer button. To its left, add **3 new buttons** in this
|
|
||||||
order (left → right): **Init Negotiations**, **Record Issue/Support**, **Record Payment**, then
|
|
||||||
the existing Edit button. All 4 buttons are the same size. Add solid single-color icons to each.
|
|
||||||
|
|
||||||
**"Init Negotiations" button** (blue/indigo accent):
|
|
||||||
- Opens a mini modal.
|
|
||||||
- Fields: Date (defaults to NOW), Title (text input, required), Note (textarea, optional).
|
|
||||||
- Auto-filled server-side: `status = "negotiating"`, `created_by` = current user,
|
|
||||||
`status_updated_date` = now, `status_updated_by` = current user,
|
|
||||||
`payment_status` defaults to zeroed object.
|
|
||||||
- On confirm: calls `POST /crm/customers/{id}/orders/init-negotiations`.
|
|
||||||
- After success: refreshes customer data and orders list. The customer's `relationship_status`
|
|
||||||
is set to `"active"` server-side — no separate frontend call needed.
|
|
||||||
- This is a fast-entry shortcut only. All subsequent edits to this order happen via the Orders tab.
|
|
||||||
|
|
||||||
**"Record Issue/Support" button** (amber/orange accent):
|
|
||||||
- Opens a mini modal.
|
|
||||||
- At the top: a **2-button toggle selector** (not a dropdown) to choose: `Technical Issue` | `Install Support`.
|
|
||||||
- Fields: Date (defaults to NOW), Note (textarea, required).
|
|
||||||
- On confirm: calls `POST /crm/customers/{id}/technical-issues` or
|
|
||||||
`POST /crm/customers/{id}/install-support` depending on selection.
|
|
||||||
|
|
||||||
**"Record Payment" button** (green accent):
|
|
||||||
- Opens a mini modal.
|
|
||||||
- Fields: Date (defaults to NOW), Payment Type (cash | bank transfer | card | paypal),
|
|
||||||
Category (full payment | advance | installment), Amount (number), Currency (defaults to EUR),
|
|
||||||
Invoice Ref (searchable over the customer's invoices, optional),
|
|
||||||
Order Ref (searchable/selectable from the customer's orders, optional),
|
|
||||||
Note (textarea, optional).
|
|
||||||
- On confirm: calls `POST /crm/customers/{id}/transactions`.
|
|
||||||
|
|
||||||
#### Overview Tab
|
|
||||||
- The main hero section gets a complete overhaul — start fresh:
|
|
||||||
- **Row 1 — Relationship Status selector**: The 5 statuses (`lead | prospect | active | inactive | churned`) as styled pill/tab buttons in a row. Current status is highlighted with a glow effect. Color-code using global CSS variables (add to `index.css` if not already present). Clicking a status immediately calls `PATCH /crm/customers/{id}/relationship-status`.
|
|
||||||
- **Row 2 — Customer info**: All fields except Name and Organization (shown in page header). Include language, religion, tags, etc.
|
|
||||||
- **Row 3 — Contacts**: All contact entries (phone, email, WhatsApp, etc.).
|
|
||||||
- **Row 4 — Notes**: Responsive column grid. 1 column below 1100px, 2 columns 1100–2000px, 3 columns above 2000px. Masonry/wrap layout with no gaps between note cards.
|
|
||||||
- Move the Latest Orders section to just below the hero section, before Latest Communications.
|
|
||||||
Hide this section entirely if no orders exist for this customer.
|
|
||||||
- For all other sections (Latest Communications, Latest Quotations, Devices): hide each section
|
|
||||||
entirely if it has no data. Show dynamically when data exists.
|
|
||||||
|
|
||||||
#### New "Support" Tab (add to TABS array, after Overview)
|
|
||||||
Two full-width section cards:
|
|
||||||
|
|
||||||
**Technical Issues Card**
|
|
||||||
- Header shows active count badge (e.g. "2 active")
|
|
||||||
- All issues listed, newest first (active and resolved)
|
|
||||||
- Each row: colored status dot, opened date, note, opened_by — "Resolve" button if active
|
|
||||||
- If more than 5 items: list is scrollable (fixed max-height), does not expand the page
|
|
||||||
- "Report New Issue" button → small inline form with note field + submit
|
|
||||||
|
|
||||||
**Install Support Card**
|
|
||||||
- Identical structure to Technical Issues card
|
|
||||||
- Same scrollable behavior if more than 5 items
|
|
||||||
|
|
||||||
#### New "Financials" Tab (add to TABS array, after Support)
|
|
||||||
Two sections:
|
|
||||||
|
|
||||||
**Active Order Payment Status** (shown only if an active order exists)
|
|
||||||
- required_amount, received_amount, balance_due
|
|
||||||
- Advance required indicator + advance amount if applicable
|
|
||||||
- Payment complete indicator
|
|
||||||
|
|
||||||
**Transaction History**
|
|
||||||
- Ledger table: Date | Flow | Amount | Currency | Method | Category | Order Ref | Invoice Ref | Note | Recorded By | Actions
|
|
||||||
- "Add Transaction" button → modal with all TransactionEntry fields
|
|
||||||
- Totals row: Total Invoiced vs Total Paid vs Outstanding Balance
|
|
||||||
- Each row: right-aligned **Actions** button (consistent with other tables in the project)
|
|
||||||
with options: **Edit** (opens edit form) and **Delete** (requires confirmation dialog)
|
|
||||||
|
|
||||||
#### Orders Tab (existing — update in place)
|
|
||||||
- Each order card/row shows:
|
|
||||||
- `title` as primary heading
|
|
||||||
- `status` with human-readable label and color coding (see Section 4)
|
|
||||||
- `payment_status` summary: required / received / balance due
|
|
||||||
- **"View Timeline"** toggle: expands a vertical event log below the order card
|
|
||||||
- **"Add Timeline Event"** button: small inline form with type dropdown + note field
|
|
||||||
- Update all API calls to use `/crm/customers/{customer_id}/orders/` routes.
|
|
||||||
|
|
||||||
### 3C. `frontend/src/crm/customers/CustomerForm.jsx`
|
|
||||||
|
|
||||||
- Remove `negotiating` and `has_problem` fields.
|
|
||||||
- Add `relationship_status` dropdown (default: `"lead"`).
|
|
||||||
- No issue/transaction forms needed here — managed from the detail page.
|
|
||||||
|
|
||||||
### 3D. `frontend/src/crm/orders/OrderForm.jsx` and `OrderDetail.jsx`
|
|
||||||
|
|
||||||
- Update status dropdown with new values and labels:
|
|
||||||
- `negotiating` → "Negotiating"
|
|
||||||
- `awaiting_quotation` → "Awaiting Quotation"
|
|
||||||
- `awaiting_customer_confirmation` → "Awaiting Customer Confirmation"
|
|
||||||
- `awaiting_fulfilment` → "Awaiting Fulfilment"
|
|
||||||
- `awaiting_payment` → "Awaiting Payment"
|
|
||||||
- `manufacturing` → "Manufacturing"
|
|
||||||
- `shipped` → "Shipped"
|
|
||||||
- `installed` → "Installed"
|
|
||||||
- `declined` → "Declined"
|
|
||||||
- `complete` → "Complete"
|
|
||||||
- Add `title` input field (required).
|
|
||||||
- Replace flat `payment_status` enum with the new `payment_status` object fields.
|
|
||||||
- Add Timeline section to `OrderDetail.jsx`: vertical event log + add-entry inline form.
|
|
||||||
- Update all API calls to use `/crm/customers/{customer_id}/orders/` routes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Status Color Coding Reference
|
|
||||||
|
|
||||||
Define all as CSS variables in `index.css` and use consistently across all views:
|
|
||||||
|
|
||||||
### Relationship Status
|
|
||||||
| Status | Color |
|
|
||||||
|---|---|
|
|
||||||
| lead | grey / muted |
|
|
||||||
| prospect | blue |
|
|
||||||
| active | green |
|
|
||||||
| inactive | amber |
|
|
||||||
| churned | dark or soft red |
|
|
||||||
|
|
||||||
### Order Status
|
|
||||||
| Status | Color |
|
|
||||||
|---|---|
|
|
||||||
| negotiating | blue |
|
|
||||||
| awaiting_quotation | purple |
|
|
||||||
| awaiting_customer_confirmation | indigo |
|
|
||||||
| awaiting_fulfilment | amber |
|
|
||||||
| awaiting_payment | orange |
|
|
||||||
| manufacturing | cyan |
|
|
||||||
| shipped | teal |
|
|
||||||
| installed | green |
|
|
||||||
| declined | red |
|
|
||||||
| complete | muted/grey |
|
|
||||||
|
|
||||||
### Issue / Support Flags
|
|
||||||
| State | Color |
|
|
||||||
|---|---|
|
|
||||||
| active issue | red |
|
|
||||||
| active support | amber |
|
|
||||||
| resolved | muted/grey |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Migration Notes
|
|
||||||
|
|
||||||
- The old `negotiating` and `has_problem` fields will remain in Firestore until the migration script is run. The backend should **read both old and new fields** during the transition period, preferring the new structure if present.
|
|
||||||
- A one-time migration script (`backend/migrate_customer_flags.py`) should:
|
|
||||||
1. Read all customer documents
|
|
||||||
2. If `negotiating: true` → create an order in the customer's `orders` subcollection with `status = "negotiating"` and set `relationship_status = "active"` on the customer
|
|
||||||
3. If `has_problem: true` → append one entry to `technical_issues` with `active: true`, `opened_date: customer.updated_at`, `note: "Migrated from legacy has_problem flag"`, `opened_by: "system"`
|
|
||||||
4. Remove `negotiating` and `has_problem` from the customer document
|
|
||||||
- Do **not** run the migration script until all frontend and backend changes are deployed and tested.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. File Summary — What to Touch
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/crm/models.py ← model updates (primary changes)
|
|
||||||
backend/crm/customers_router.py ← new endpoints + field updates
|
|
||||||
backend/crm/orders_router.py ← remove top-level routes, re-implement as subcollection,
|
|
||||||
add timeline + payment-status + init-negotiations endpoints
|
|
||||||
backend/migrate_customer_flags.py ← NEW one-time migration script
|
|
||||||
|
|
||||||
frontend/src/index.css ← add CSS variables for all new status colors
|
|
||||||
frontend/src/crm/customers/CustomerList.jsx ← relationship status chip + support flag dots column
|
|
||||||
frontend/src/crm/customers/CustomerDetail.jsx ← page header, 3 new quick-entry buttons + modals,
|
|
||||||
Overview tab overhaul, new Support tab,
|
|
||||||
new Financials tab, Orders tab updates
|
|
||||||
frontend/src/crm/customers/CustomerForm.jsx ← remove old flags, add relationship_status
|
|
||||||
frontend/src/crm/orders/OrderForm.jsx ← new status values, title field, payment_status,
|
|
||||||
updated API route paths
|
|
||||||
frontend/src/crm/orders/OrderDetail.jsx ← timeline section, updated status/payment,
|
|
||||||
updated API route paths
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Do NOT Change (out of scope)
|
|
||||||
|
|
||||||
- Quotations system — leave as-is
|
|
||||||
- Communications / inbox — leave as-is
|
|
||||||
- Files & Media tab — leave as-is
|
|
||||||
- Devices tab — leave as-is
|
|
||||||
- Any other module outside `crm/`
|
|
||||||
627
DESIGN.md
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
# DESIGN SYSTEM — BellSystems Control Panel v2
|
||||||
|
|
||||||
|
> Single source of truth for all UI/UX decisions.
|
||||||
|
> Read before writing any page, component, or modal.
|
||||||
|
> Never override these rules inline. Change the rule here first, then propagate.
|
||||||
|
>
|
||||||
|
> Live reference: `/dev/styleguide` — every component, every variant, every state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Philosophy
|
||||||
|
|
||||||
|
- **Consistency over creativity.** Every page must feel like it belongs to the same product.
|
||||||
|
- **Tokens over hardcoded values.** Never write a raw color, spacing value, or font size. Always `var(--token)`.
|
||||||
|
- **Components over repetition.** If you write the same pattern twice, it becomes a shared component.
|
||||||
|
- **Page layout is global.** All pages share the same padding and spacing anchors. Content always starts at the same position.
|
||||||
|
- **Accessible by default.** ARIA labels, keyboard navigation, visible focus states on all interactive elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Page Layout Anatomy
|
||||||
|
|
||||||
|
Every authenticated page lives inside `MainLayout`, which provides:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬────────────────────────────────────────────┐
|
||||||
|
│ │ HEADER (height: 56px / --header-height) │
|
||||||
|
│ │────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ SIDEBAR │ CONTENT AREA │
|
||||||
|
│ (224px / │ ┌──────────────────────────────────────┐ │
|
||||||
|
│ --sidebar- │ │ .page-wrapper │ │
|
||||||
|
│ width) │ │ padding: 48px (--space-12) │ │
|
||||||
|
│ │ │ gap: 24px between sections │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ <PageHeader> ← always first │ │
|
||||||
|
│ │ │ <content...> │ │
|
||||||
|
│ │ └──────────────────────────────────────┘ │
|
||||||
|
└─────────────┴────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### The consistency guarantee
|
||||||
|
|
||||||
|
`.page-wrapper` is defined once in `global.css`. It is the only wrapper used on every page:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.page-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-12); /* 48px — desktop */
|
||||||
|
gap: var(--space-6); /* 24px between direct children */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Mobile: padding drops to --space-8 (32px), gap to --space-4 (16px) */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Every page's root element is `<div className="page-wrapper">` — no exceptions
|
||||||
|
- Never add extra padding, margin, or wrapper divs that shift content relative to `.page-wrapper`
|
||||||
|
- Never override `.page-wrapper` padding per-page
|
||||||
|
- The `<PageHeader>` is always the first child inside `.page-wrapper`
|
||||||
|
- This ensures that on every page, the title starts at exactly 48px from the top-left corner of the content area
|
||||||
|
|
||||||
|
### Content width modes
|
||||||
|
|
||||||
|
Every page falls into one of two modes:
|
||||||
|
|
||||||
|
#### 1. Full-width (default)
|
||||||
|
Content expands to fill the entire available area. Use this for all data-heavy pages: lists, tables, dashboards, detail views.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="page-wrapper">
|
||||||
|
{/* content fills the full content area */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Centered (narrow content)
|
||||||
|
For pages with a small number of elements that would look lost spanning the full viewport — e.g. settings forms, auth pages, single-entity configuration screens.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div className="page-wrapper page-wrapper--centered">
|
||||||
|
{/* every direct child is capped at --content-max-width-sm (640px) and centered */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Default max-width is `--content-max-width-sm` (640px). Override per-page only when necessary:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div
|
||||||
|
className="page-wrapper page-wrapper--centered"
|
||||||
|
style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
Available width tokens:
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-----------------------------|--------|-----------------------------------------|
|
||||||
|
| `--content-max-width-xs` | 480px | Tiny forms, login, auth |
|
||||||
|
| `--content-max-width-sm` | 640px | Small forms, simple settings (default) |
|
||||||
|
| `--content-max-width-md` | 800px | Medium forms, detail-light pages |
|
||||||
|
| `--content-max-width-lg` | 1024px | Moderate-width constrained pages |
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Never use `page-wrapper--centered` on a list page, data table page, or any page where content should grow with the viewport
|
||||||
|
- Never hardcode a pixel `max-width` in a page file — always use a token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Color Tokens
|
||||||
|
|
||||||
|
All colors are CSS custom properties defined in `frontend/src/styles/tokens.css`.
|
||||||
|
The system is **dark-first**: `:root` = dark theme. `[data-theme="light"]` overrides exist as a placeholder.
|
||||||
|
|
||||||
|
### Rule: never write a raw color value in any component or page file. Always `var(--token)`.
|
||||||
|
|
||||||
|
### Background Surfaces (7-step tonal ladder)
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|------------------------|--------------|--------------------------------------------------------|
|
||||||
|
| `--color-bg-abyss` | `#0a0e14` | Deepest well: code blocks, input backgrounds |
|
||||||
|
| `--color-bg-base` | `#10141a` | Page background (viewport fill) |
|
||||||
|
| `--color-bg-void` | `#181c22` | Sidebar, header |
|
||||||
|
| `--color-bg-surface` | `#1c2026` | Default card / panel background |
|
||||||
|
| `--color-bg-elevated` | `#262a31` | Raised cards, hovered rows, dropdowns |
|
||||||
|
| `--color-bg-island` | `#31353c` | Active states, selected rows, pressed buttons |
|
||||||
|
| `--color-bg-float` | `rgba(53,57,64,0.80)` | Glassmorphism: modals, floating panels |
|
||||||
|
|
||||||
|
### Brand / Primary (Indigo Glow)
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|----------------------------|------------------------------|--------------------------------------|
|
||||||
|
| `--color-primary` | `#c0c1ff` | CTAs, active nav, key accent |
|
||||||
|
| `--color-primary-hover` | `#d2bbff` | Hover, gradient endpoint |
|
||||||
|
| `--color-primary-container`| `#8083ff` | Container fills |
|
||||||
|
| `--color-primary-subtle` | `rgba(128,131,255,0.12)` | Hover backgrounds, tinted areas |
|
||||||
|
| `--gradient-primary` | `linear-gradient(135deg, #c0c1ff, #d2bbff)` | Primary button fill |
|
||||||
|
|
||||||
|
### Semantic / State Colors
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|------------------------|---------------------------|------------------------------------------------|
|
||||||
|
| `--color-success` | `#4ade80` | Online, active, confirmed |
|
||||||
|
| `--color-success-bg` | `rgba(74,222,128,0.12)` | Success badge / button resting background |
|
||||||
|
| `--color-warning` | `#fbbf24` | Pending, needs attention |
|
||||||
|
| `--color-warning-bg` | `rgba(251,191,36,0.12)` | Warning badge / button resting background |
|
||||||
|
| `--color-danger` | `#ff5c5c` | Error text, destructive actions |
|
||||||
|
| `--color-danger-bg` | `rgba(255,92,92,0.12)` | Danger badge / button resting background |
|
||||||
|
| `--color-info` | `#7bd0ff` | Informational, aqua-sky accent |
|
||||||
|
| `--color-info-bg` | `rgba(123,208,255,0.12)` | Info badge background |
|
||||||
|
|
||||||
|
### Text Colors (4-step hierarchy)
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---------------------------|-------------|---------------------------------------------------|
|
||||||
|
| `--color-text-primary` | `#dfe2eb` | Body copy, data values, headings |
|
||||||
|
| `--color-text-secondary` | `#c7c4d7` | Labels, metadata, inactive nav |
|
||||||
|
| `--color-text-muted` | `#908fa0` | Placeholders, disabled, category headers |
|
||||||
|
| `--color-text-inverse` | `#10141a` | Text on primary/accent backgrounds (dark on light)|
|
||||||
|
| `--color-text-accent` | `#c0c1ff` | Active nav items, links |
|
||||||
|
|
||||||
|
### Borders
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------------------------|----------------------------|--------------------------------------------|
|
||||||
|
| `--color-border` | `rgba(70,69,84,0.20)` | Resting inputs, card outlines |
|
||||||
|
| `--color-border-strong` | `rgba(70,69,84,0.45)` | Secondary buttons, stronger dividers |
|
||||||
|
| `--color-border-focus` | `rgba(192,193,255,0.40)` | Focus ring halo on inputs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Typography
|
||||||
|
|
||||||
|
Two-font system. Three families total.
|
||||||
|
|
||||||
|
### Font Families
|
||||||
|
|
||||||
|
| Token | Font | Role |
|
||||||
|
|--------------------------|---------------------|---------------------------------------------------|
|
||||||
|
| `--font-family-display` | `Barlow Condensed` | H1, H2, page titles, modal titles |
|
||||||
|
| `--font-family-base` | `Onest` | All UI text, body, labels, buttons, table rows |
|
||||||
|
| `--font-family-mono` | `JetBrains Mono` | Serial numbers, IDs, code, API keys, terminal |
|
||||||
|
|
||||||
|
**Why this pairing:**
|
||||||
|
- `Barlow Condensed` has an industrial/engineering quality — feels like instrument panel labelling. Makes page titles immediately distinctive.
|
||||||
|
- `Onest` is a Ukrainian geometric grotesque with slightly unusual proportions and excellent numerics. Clean at 14px. Not the overused Inter/Space Grotesk.
|
||||||
|
- `JetBrains Mono` is the standard for developer-facing data.
|
||||||
|
|
||||||
|
### Font Sizes
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|--------------------|------------|----------------------------------------------|
|
||||||
|
| `--font-size-xs` | `0.6875rem` (11px) | Labels, sidebar category headers, chips |
|
||||||
|
| `--font-size-sm` | `0.75rem` (12px) | Captions, helper text, table headers |
|
||||||
|
| `--font-size-base` | `0.875rem` (14px) | Body text, table rows (default) |
|
||||||
|
| `--font-size-md` | `1rem` (16px) | Card titles, module headers |
|
||||||
|
| `--font-size-lg` | `1.125rem` (18px) | Section subheadings |
|
||||||
|
| `--font-size-xl` | `1.5rem` (24px) | Page headings (h1/h2) |
|
||||||
|
| `--font-size-2xl` | `3.5rem` (56px) | Hero KPI numbers, dashboard metrics |
|
||||||
|
|
||||||
|
### Font Weights
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---------------------------|-------|----------------------------------------|
|
||||||
|
| `--font-weight-normal` | 400 | Body copy |
|
||||||
|
| `--font-weight-medium` | 500 | Emphasized body, table values |
|
||||||
|
| `--font-weight-semibold` | 600 | Headings, button labels, field labels |
|
||||||
|
| `--font-weight-bold` | 700 | Strong emphasis, hero metrics |
|
||||||
|
|
||||||
|
### Typography Usage Rules
|
||||||
|
|
||||||
|
- **Page titles (`<PageHeader>`):** `Barlow Condensed`, `1.75rem`, weight 600 (handled by `.v2-page-header-title`)
|
||||||
|
- **Modal titles:** `Barlow Condensed`, `1.125rem`, weight 600 (handled by `.v2-modal-title`)
|
||||||
|
- **H1, H2 globally:** `Barlow Condensed`, `var(--font-size-xl)`, weight 600 — set in `global.css`
|
||||||
|
- **H3–H6:** `Onest` (body font), normal heading weights
|
||||||
|
- **Card titles:** `Onest`, `--font-size-base`, weight 600
|
||||||
|
- **Table headers:** `Onest`, `--font-size-sm`, weight 600, uppercase, `--tracking-wide`
|
||||||
|
- **Body / cell text:** `Onest`, `--font-size-base`, weight 400
|
||||||
|
- **Muted / helper text:** `Onest`, `--font-size-sm`, `--color-text-muted`
|
||||||
|
- **Serials, IDs, codes:** `JetBrains Mono`, `--font-size-sm`
|
||||||
|
|
||||||
|
### Letter Spacing
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---------------------|-----------|--------------------------------------------|
|
||||||
|
| `--tracking-normal` | `0em` | Default |
|
||||||
|
| `--tracking-tight` | `-0.01em` | Barlow Condensed headings |
|
||||||
|
| `--tracking-wide` | `0.08em` | Uppercase labels, sidebar category headers |
|
||||||
|
| `--tracking-display`| `-0.02em` | Hero KPI numbers at 56px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. Date, Time & Currency Formatting
|
||||||
|
|
||||||
|
All dates use **Greek/European style** (day-first). Never use US-style MM/DD/YYYY anywhere in the app.
|
||||||
|
|
||||||
|
All formatting is centralized in `frontend/src/lib/formatters.js`. Never use raw `toLocaleDateString()`, `Intl.DateTimeFormat`, `toLocaleString()`, or `toISOString().slice()` in pages or modals — always import from `@/lib/formatters`.
|
||||||
|
|
||||||
|
### Available formatters
|
||||||
|
|
||||||
|
| Function | Output example | Use for |
|
||||||
|
|---------------------|------------------------------------|--------------------------------------|
|
||||||
|
| `fmtDate` | `05/03/2026` | Short numeric dates (tables, lists) |
|
||||||
|
| `fmtDateMedium` | `5 Mar 2026` | Medium dates (cards, details) |
|
||||||
|
| `fmtDateLong` | `5 March 2026` | Long dates (headings, summaries) |
|
||||||
|
| `fmtDateFull` | `Wednesday, 5 March 2026` | Dashboard, full context |
|
||||||
|
| `fmtDateTime` | `5 March 2026, 2:30 pm` | Date + 12h time |
|
||||||
|
| `fmtDateTimeMedium` | `5 Mar 2026, 14:30` | Date + 24h time (compact) |
|
||||||
|
| `fmtDateTimeFull` | `Wed, 5 Mar 2026, 2:30 pm` | Emails, comms |
|
||||||
|
| `fmtRelative` | `5 minutes ago` | Relative timestamps |
|
||||||
|
| `fmtEuro` | `1.250,00 €` | Euro currency (Greek locale) |
|
||||||
|
|
||||||
|
### Form input helpers
|
||||||
|
|
||||||
|
| Function | Output example | Use for |
|
||||||
|
|---------------------|-------------------------|--------------------------------------------------|
|
||||||
|
| `toDatetimeLocal` | `2026-03-05T14:30` | Populating `datetime-local` inputs (local time) |
|
||||||
|
| `nowLocal` | `2026-03-05T14:30` | Current time for form defaults |
|
||||||
|
| `toDateInput` | `2026-03-05` | Populating `date` inputs |
|
||||||
|
|
||||||
|
### Critical rule: no `toISOString().slice()` for form inputs
|
||||||
|
|
||||||
|
`toISOString()` converts to **UTC**, which shifts the time by the user's timezone offset (e.g. 3 hours for Greece). Always use `toDatetimeLocal()` or `nowLocal()` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Spacing System
|
||||||
|
|
||||||
|
4px base unit. All spacing must use tokens — no arbitrary pixel values.
|
||||||
|
|
||||||
|
| Token | Value | Common use |
|
||||||
|
|--------------|--------|--------------------------------------------------|
|
||||||
|
| `--space-1` | 4px | Tight gaps, icon padding |
|
||||||
|
| `--space-2` | 8px | Between label and input, inline gaps |
|
||||||
|
| `--space-3` | 12px | Table cell padding, compact button padding |
|
||||||
|
| `--space-4` | 16px | Between form fields, mobile page padding |
|
||||||
|
| `--space-5` | 20px | Tab item spacing |
|
||||||
|
| `--space-6` | 24px | **Page padding**, card padding, section gap |
|
||||||
|
| `--space-8` | 32px | Between major sections |
|
||||||
|
| `--space-10` | 40px | Large section gap |
|
||||||
|
| `--space-12` | 48px | Extra large spacing |
|
||||||
|
| `--space-16` | 64px | Maximum spacing, hero sections |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Border Radius & Shadows
|
||||||
|
|
||||||
|
### Border Radius
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|----------------|----------|-------------------------------------------|
|
||||||
|
| `--radius-sm` | 4px | Tags, small chips, select option rows |
|
||||||
|
| `--radius-md` | 6px | Buttons, inputs, table badges |
|
||||||
|
| `--radius-lg` | 8px | Cards, panels, dropdown menus |
|
||||||
|
| `--radius-xl` | 12px | Modals, large containers |
|
||||||
|
| `--radius-full`| 9999px | Status badge pills, avatars |
|
||||||
|
|
||||||
|
### Shadows
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|-------------------------|------------------------------------------|------------------------------------|
|
||||||
|
| `--shadow-card` | `inset 0 1px 0 rgba(192,193,255,0.05)` | Card top-edge glass reflection |
|
||||||
|
| `--shadow-sm` | `0 2px 8px rgba(10,14,20,0.40)` | Subtle lift |
|
||||||
|
| `--shadow-md` | `0 4px 16px rgba(10,14,20,0.50)` | Elevated cards |
|
||||||
|
| `--shadow-lg` | `0 8px 24px rgba(13,17,23,0.60)` | Modals, dropdowns |
|
||||||
|
| `--shadow-focus` | `0 0 0 3px rgba(192,193,255,0.20)` | Focus ring glow |
|
||||||
|
| `--shadow-primary-glow` | `0 4px 16px rgba(192,193,255,0.28)` | Primary button hover halo |
|
||||||
|
| `--shadow-danger-glow` | `0 4px 16px rgba(255,92,92,0.40)` | Danger button hover halo |
|
||||||
|
| `--shadow-success-glow` | `0 4px 16px rgba(74,222,128,0.35)` | Success button hover halo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Component Rules
|
||||||
|
|
||||||
|
### Button
|
||||||
|
|
||||||
|
Import: `@/components/ui/Button`
|
||||||
|
|
||||||
|
**Variants:**
|
||||||
|
|
||||||
|
| Variant | Resting state | Hover state |
|
||||||
|
|------------------|---------------------------------------|----------------------------------------------------------|
|
||||||
|
| `primary` | Indigo→violet gradient, dark text | `+brightness(1.06)` + `--shadow-primary-glow` halo |
|
||||||
|
| `secondary` | Island bg, ghost border | Elevated bg + focus border + subtle indigo glow |
|
||||||
|
| `ghost` | Transparent | Elevated bg + whisper indigo glow |
|
||||||
|
| `danger` | Coral tint bg, coral text | **Solid coral fill**, dark text + `--shadow-danger-glow` |
|
||||||
|
| `success` | Emerald tint bg, emerald text | **Solid emerald fill**, dark text + `--shadow-success-glow` |
|
||||||
|
| `table-actions` | Fully transparent, muted text | Island bg + strong border (identical to `secondary`) — also activates on `tr:hover` |
|
||||||
|
|
||||||
|
**Sizes:** `sm`, `md` (default), `lg`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Never use a raw `<button>` element for a styled action
|
||||||
|
- Always pass `loading` prop for async actions (shows spinner, disables interaction)
|
||||||
|
- Icon-only buttons must have `aria-label`
|
||||||
|
- Active/press state: `filter: brightness(0.94)`, glow removed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### FormField
|
||||||
|
|
||||||
|
Import: `@/components/ui/FormField`
|
||||||
|
|
||||||
|
Wraps every form control: label + input/textarea/select + hint + error message.
|
||||||
|
Never place a raw `<input>` on a page.
|
||||||
|
|
||||||
|
**Types:** `text`, `email`, `password`, `number`, `tel`, `url`, `textarea`, `select`
|
||||||
|
|
||||||
|
**For `type="select"`**: pass `<option>` elements as children. FormField uses the custom `Select` component internally — the native `<select>` is never rendered.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<FormField label="Status" name="status" type="select" value={val} onChange={handleChange}>
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</FormField>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Input appearance:** "cutout" inset-shadow treatment — the field appears recessed into the surface. Background: `--color-bg-abyss`. Focus: `--color-border-focus` ring.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Select (standalone)
|
||||||
|
|
||||||
|
Import: `@/components/ui/Select`
|
||||||
|
|
||||||
|
Fully custom dropdown replacing native `<select>`. Floating menu via portal, keyboard navigation, checkmark on selected item. Usually consumed via `FormField type="select"`. Use directly when you need a select outside a form label context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DataTable
|
||||||
|
|
||||||
|
Import: `@/components/ui/DataTable`
|
||||||
|
|
||||||
|
**Always include:** column headers, loading skeleton, empty state, pagination.
|
||||||
|
**Rows:** alternate tint via `--color-tint-row` (`rgba(192,193,255,0.015)`). Hover: `--color-bg-island`.
|
||||||
|
**Status columns:** always `<StatusBadge>` — never raw text.
|
||||||
|
**Row actions:** last column, right-aligned, use portal-based action menu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Modal
|
||||||
|
|
||||||
|
Import: `@/components/ui/Modal`
|
||||||
|
|
||||||
|
Sizes: `sm` (480px), `md` (640px — default), `lg` (800px), `xl` (60vw/60vh), `xxl` (85vw/85vh), `full` (calc(100vw/100vh − 64px)).
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- Always has: title, close (×) button, footer action buttons
|
||||||
|
- Closes on Escape + backdrop click unless `persistent={true}`
|
||||||
|
- Destructive prompts use `<ConfirmDialog>` instead
|
||||||
|
- Modal JSX never lives inside a page file — always in `frontend/src/modals/[domain]/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ConfirmDialog
|
||||||
|
|
||||||
|
Import: `@/components/ui/ConfirmDialog`
|
||||||
|
|
||||||
|
Wraps `<Modal size="sm">` with a centred icon + message. Use for any action that is destructive or hard to reverse.
|
||||||
|
|
||||||
|
Variants: `danger` (coral circle + triangle icon), `primary` (indigo circle + info icon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PageHeader
|
||||||
|
|
||||||
|
Import: `@/components/ui/PageHeader`
|
||||||
|
|
||||||
|
**Always the first element inside `.page-wrapper`.** Creates the page title block.
|
||||||
|
|
||||||
|
Props: `title` (required), `subtitle`, `breadcrumbs`, `children` (action buttons slot).
|
||||||
|
|
||||||
|
The title renders as `<h1>` with class `.v2-page-header-title` — uses `Barlow Condensed` at `1.75rem` / weight 600.
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<PageHeader title="Device Inventory" subtitle="All registered Bell units">
|
||||||
|
<Button variant="primary">Add Device</Button>
|
||||||
|
</PageHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Card
|
||||||
|
|
||||||
|
Import: `@/components/ui/Card`
|
||||||
|
|
||||||
|
Variants: `flat` (default), `elevated`, `outlined`.
|
||||||
|
|
||||||
|
Props: `title`, `subtitle`, `footer`, `padding` (bool, default true), `children`.
|
||||||
|
|
||||||
|
Card header has a faint indigo gradient ceiling (`linear-gradient` from top).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
Import: `@/components/ui/Tabs`
|
||||||
|
|
||||||
|
Variants: `line` (default — underline indicator), `pill` (filled background).
|
||||||
|
|
||||||
|
Props: `tabs` (array of `{key, label, icon?, count?}`), `active`, `onChange`, `variant`.
|
||||||
|
|
||||||
|
Line variant uses a sliding indicator measured with `useLayoutEffect`. Pill variant uses filled backgrounds.
|
||||||
|
|
||||||
|
Spacing: line tabs have `gap: --space-5` between items, pill tabs `gap: --space-4`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Toast
|
||||||
|
|
||||||
|
Import: `@/components/ui/Toast` → `{ ToastProvider, useToast }`
|
||||||
|
|
||||||
|
Setup: wrap the app (or router) with `<ToastProvider>`. Then in any component:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const toast = useToast()
|
||||||
|
toast.success('Saved', 'Device updated successfully.')
|
||||||
|
toast.danger('Error', 'Failed to connect.')
|
||||||
|
toast.warning('Warning', 'Firmware is outdated.')
|
||||||
|
toast.info('Info', 'Sync in progress.')
|
||||||
|
```
|
||||||
|
|
||||||
|
Toasts auto-dismiss after 4000ms. Hover pauses the timer. Stack appears in the bottom-right corner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SearchBar
|
||||||
|
|
||||||
|
Import: `@/components/ui/SearchBar`
|
||||||
|
|
||||||
|
Supports controlled (`value` + `onChange`) or uncontrolled mode.
|
||||||
|
Debounced by default (300ms). Clear button appears when text is present.
|
||||||
|
Appearance matches the `FormField` cutout treatment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Breadcrumbs
|
||||||
|
|
||||||
|
Import: `@/components/ui/Breadcrumbs`
|
||||||
|
|
||||||
|
Use on detail pages only (not list pages). Items: array of `{ label, href? }`. Last item has no href — it is the current page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Spinner
|
||||||
|
|
||||||
|
Import: `@/components/ui/Spinner`
|
||||||
|
|
||||||
|
Props: `size` (`sm`, `md`, `lg`), `color` (defaults to `--color-primary`).
|
||||||
|
|
||||||
|
Use inside loading states. Buttons show their own spinner via `loading` prop — do not add a separate `<Spinner>` inside buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### StatusBadge
|
||||||
|
|
||||||
|
Import: `@/components/ui/StatusBadge`
|
||||||
|
|
||||||
|
Never use a raw `<span>` with a background color for status. Always `<StatusBadge>`.
|
||||||
|
|
||||||
|
Variants: `success`, `warning`, `danger`, `info`, `neutral`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Icon
|
||||||
|
|
||||||
|
Import: `@/components/ui/Icon`
|
||||||
|
|
||||||
|
Renders an inline SVG by name. 35 named icons available (see Style Guide `/dev/styleguide` → Icon section for the full list).
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<Icon name="edit" size={16} />
|
||||||
|
<Icon name="delete" size={20} color="var(--color-danger)" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Asset SVGs** (from `/assets/` folders) are displayed via `<img>` tags in the Style Guide, not via `<Icon>`. These are pre-rendered SVG files used for sidebar icons, comms icons, customer status icons, etc. Use them as image sources, not as Icon component names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Icons
|
||||||
|
|
||||||
|
Three sources:
|
||||||
|
|
||||||
|
| Source | Use case | How to render |
|
||||||
|
|-------------------------------------|---------------------------------------|-----------------------------|
|
||||||
|
| `<Icon name="..." />` | Action icons, UI chrome | `@/components/ui/Icon` |
|
||||||
|
| `assets/side-menu-icons/*.svg` | Sidebar navigation | `<img src={...} />` |
|
||||||
|
| `assets/comms/*.svg` | Communication type indicators | `<img src={...} />` |
|
||||||
|
| `assets/customer-status/*.svg` | CRM status icons | `<img src={...} />` |
|
||||||
|
| `assets/global-icons/*.svg` | Legacy action icons (prefer `<Icon>`) | `<img src={...} />` |
|
||||||
|
| `assets/other-icons/*.svg` | Misc UI icons | `<img src={...} />` |
|
||||||
|
|
||||||
|
Never add a new icon library (e.g. heroicons, lucide). Use the existing sources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Theming Rules
|
||||||
|
|
||||||
|
- Theme is controlled by `data-theme` attribute on `<html>`
|
||||||
|
- Default is dark (`:root` = dark theme)
|
||||||
|
- **Never** use Tailwind's `dark:` prefix — theming is handled entirely via CSS tokens
|
||||||
|
- `[data-theme="light"]` overrides exist in `tokens.css` as a future placeholder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Responsive Breakpoints
|
||||||
|
|
||||||
|
| Token | Value | Behaviour |
|
||||||
|
|--------------------|--------|--------------------------------------------------------|
|
||||||
|
| `--breakpoint-sm` | 640px | |
|
||||||
|
| `--breakpoint-md` | 768px | Sidebar collapses; page padding drops to `--space-4` |
|
||||||
|
| `--breakpoint-lg` | 1024px | Full sidebar shown |
|
||||||
|
| `--breakpoint-xl` | 1280px | |
|
||||||
|
|
||||||
|
Mobile (`< 768px`): single column, sidebar hidden (drawer), tables may become card lists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Section Layout — Masonry Grid
|
||||||
|
|
||||||
|
**Default layout for ALL content pages with multiple variable-height sections.**
|
||||||
|
|
||||||
|
Sections on a content page must flow like physical objects stacked in columns — the next section always drops into the shortest column. This is CSS column masonry.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
Column 1 | Column 2 | Column 3
|
||||||
|
────────────┼─────────────┼────────────
|
||||||
|
Section A | Section B | Section C
|
||||||
|
(300px) | (250px) | (350px)
|
||||||
|
│ │
|
||||||
|
Section E | Section D |
|
||||||
|
(200px) | (200px) |
|
||||||
|
```
|
||||||
|
|
||||||
|
Sections fill left-to-right across the top, then each new section drops into whichever column is currently shortest. This is automatic — the browser handles placement via CSS `columns`.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
{/* 2 columns */}
|
||||||
|
<div className="masonry-grid masonry-grid--2">
|
||||||
|
<Card title="Account Info">…</Card>
|
||||||
|
<Card title="Profile">…</Card>
|
||||||
|
<Card title="Security">…</Card> {/* auto-drops into shortest column */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 columns */}
|
||||||
|
<div className="masonry-grid masonry-grid--3">
|
||||||
|
{sections.map(s => <Card key={s.id}>…</Card>)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Available variants: `masonry-grid--2`, `masonry-grid--3`, `masonry-grid--4`
|
||||||
|
|
||||||
|
Responsive behaviour:
|
||||||
|
- `--4` collapses to 3 cols at 1024px, 1 col at 768px
|
||||||
|
- `--3` collapses to 2 cols at 1024px, 1 col at 768px
|
||||||
|
- `--2` collapses to 1 col at 768px
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Use `.masonry-grid` by default** on all content pages with 2+ variable-height sections
|
||||||
|
- **Do NOT** use `display: grid` with `gridTemplateColumns` for variable-height card layouts — this creates uneven whitespace when cards differ in height
|
||||||
|
- **Do NOT** use `.masonry-grid` for DataTable pages — tables span full width on their own
|
||||||
|
- **Do NOT** use `.masonry-grid` when sections must align horizontally (e.g. two fields that are semantically paired side-by-side within a card) — that's an internal card layout, not page-level masonry
|
||||||
|
- The `Card` component already has `break-inside: avoid` so it will never be split across columns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. What Claude Code Must NEVER Do
|
||||||
|
|
||||||
|
- ❌ Write a hex color, `rgb()`, or `hsl()` value directly in any component or page file
|
||||||
|
- ❌ Write a pixel spacing or size value that isn't a `--space-*` token
|
||||||
|
- ❌ Use a raw `<button>`, `<input>`, or `<select>` for anything styled — always use the wrapper component
|
||||||
|
- ❌ Create a `.module.css` or any per-page CSS file
|
||||||
|
- ❌ Use Tailwind's `dark:` prefix — theming is via CSS tokens only
|
||||||
|
- ❌ Place modal JSX inside a page file — modals live in `frontend/src/modals/`
|
||||||
|
- ❌ Wrap `.page-wrapper` in additional divs that shift content alignment
|
||||||
|
- ❌ Override `.page-wrapper`'s padding to make a single page "different"
|
||||||
|
- ❌ Skip loading, error, and empty states on any data-fetching component
|
||||||
|
- ❌ Import from `_archive/` anywhere except `@/lib/api.js`, `@/hooks/useAuth.js`, and `@/providers/AuthProvider.jsx`
|
||||||
|
- ❌ Install a new icon library or introduce new SVG icons outside of `assets/`
|
||||||
|
- ❌ Invent new color values not in `tokens.css`
|
||||||
113
backend/alembic.ini
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||||
|
# Any required deps can installed by adding `tzdata` to the `[alembic]` section
|
||||||
|
# of pyproject.toml.
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in "version_locations" directory
|
||||||
|
# New in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# NOTE: The database URL is set programmatically in env.py from settings.
|
||||||
|
# Do not set sqlalchemy.url here.
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
44
backend/alembic/env.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from alembic import context
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
# Import all models so Alembic can see them
|
||||||
|
from database.models import Base # noqa: F401 — triggers all ORM imports
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = settings.database_url
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
engine = create_async_engine(settings.database_url)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(do_run_migrations)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""rename_entries_to_crm_entries
|
||||||
|
|
||||||
|
Revision ID: 244a0b0f35be
|
||||||
|
Revises: 485d40e86e4b
|
||||||
|
Create Date: 2026-04-15 20:05:20.835281
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '244a0b0f35be'
|
||||||
|
down_revision: Union[str, None] = '485d40e86e4b'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Drop FK from support_tickets -> entries before touching the entries table
|
||||||
|
op.drop_constraint('support_tickets_linked_entry_id_fkey', 'support_tickets', type_='foreignkey')
|
||||||
|
# 2. Drop dependent table first, then parent
|
||||||
|
op.drop_table('entry_links')
|
||||||
|
op.drop_table('entries')
|
||||||
|
# 3. Create new tables with crm_ prefix
|
||||||
|
op.create_table('crm_entries',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('body', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('severity', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('author_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('author_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('crm_entry_links',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('entry_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entry_id'], ['crm_entries.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id')
|
||||||
|
)
|
||||||
|
# 4. Recreate FK on support_tickets pointing at new table
|
||||||
|
op.create_foreign_key(None, 'support_tickets', 'crm_entries', ['linked_entry_id'], ['id'], ondelete='SET NULL')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'support_tickets', type_='foreignkey')
|
||||||
|
op.create_foreign_key('support_tickets_linked_entry_id_fkey', 'support_tickets', 'entries', ['linked_entry_id'], ['id'], ondelete='SET NULL')
|
||||||
|
op.create_table('entries',
|
||||||
|
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('type', sa.VARCHAR(length=10), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('title', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('body', sa.TEXT(), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('status', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('severity', sa.VARCHAR(length=10), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('author_id', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('author_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
|
||||||
|
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='entries_pkey'),
|
||||||
|
postgresql_ignore_search_path=False
|
||||||
|
)
|
||||||
|
op.create_table('entry_links',
|
||||||
|
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('entry_id', sa.UUID(), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('entity_type', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||||
|
sa.Column('entity_id', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entry_id'], ['entries.id'], name='entry_links_entry_id_fkey', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name='entry_links_pkey'),
|
||||||
|
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id', name='entry_links_entry_id_entity_type_entity_id_key')
|
||||||
|
)
|
||||||
|
op.drop_table('crm_entry_links')
|
||||||
|
op.drop_table('crm_entries')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""initial_notes_and_tickets
|
||||||
|
|
||||||
|
Revision ID: 485d40e86e4b
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-15 20:01:04.225959
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '485d40e86e4b'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('entries',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('body', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('severity', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('author_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('author_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('entry_links',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('entry_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['entry_id'], ['entries.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('entry_id', 'entity_type', 'entity_id')
|
||||||
|
)
|
||||||
|
op.create_table('support_tickets',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('customer_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('customer_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('device_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('device_serial', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('subject', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('priority', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('opened_via', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('linked_entry_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['linked_entry_id'], ['entries.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('ticket_messages',
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('ticket_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('sender_type', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('sender_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('sender_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('body', sa.Text(), nullable=False),
|
||||||
|
sa.Column('is_internal', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['ticket_id'], ['support_tickets.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('ticket_messages')
|
||||||
|
op.drop_table('support_tickets')
|
||||||
|
op.drop_table('entry_links')
|
||||||
|
op.drop_table('entries')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add_category_to_crm_entries
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 244a0b0f35be
|
||||||
|
Create Date: 2026-04-16 09:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = 'a1b2c3d4e5f6'
|
||||||
|
down_revision: Union[str, None] = '244a0b0f35be'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('crm_entries', sa.Column('category', sa.String(length=30), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('crm_entries', 'category')
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
"""phase_0_schema_foundation
|
||||||
|
|
||||||
|
Adds all Phase 0 tables:
|
||||||
|
- _migration_runs (migration tracking)
|
||||||
|
- audit_log (staff action audit trail)
|
||||||
|
- crm_products
|
||||||
|
- crm_customers
|
||||||
|
- crm_orders
|
||||||
|
- crm_comms_log
|
||||||
|
- crm_media
|
||||||
|
- crm_sync_state
|
||||||
|
- crm_quotations
|
||||||
|
- crm_quotation_items
|
||||||
|
- staff
|
||||||
|
- console_settings
|
||||||
|
- public_features
|
||||||
|
- melody_drafts
|
||||||
|
- built_melodies
|
||||||
|
- mfg_audit_log
|
||||||
|
- device_alerts
|
||||||
|
- commands (raw SQL — no ORM model)
|
||||||
|
- heartbeats (raw SQL — no ORM model)
|
||||||
|
- device_logs (partitioned by month — raw SQL)
|
||||||
|
- device_logs_2025_01 … device_logs_2026_06 (initial partitions)
|
||||||
|
|
||||||
|
Revision ID: b1c2d3e4f5a6
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-04-17 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision: str = "b1c2d3e4f5a6"
|
||||||
|
down_revision: Union[str, None] = "a1b2c3d4e5f6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _migration_runs
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"_migration_runs",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("script_name", sa.String(256), nullable=False),
|
||||||
|
sa.Column("ran_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("source_rows", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("dest_rows", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("success", sa.String(8), nullable=False, server_default="ok"),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# audit_log
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"audit_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("actor_id", sa.String(128), nullable=False),
|
||||||
|
sa.Column("actor_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("action", sa.String(64), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(64), nullable=False),
|
||||||
|
sa.Column("entity_id", sa.String(128), nullable=False),
|
||||||
|
sa.Column("entity_label", sa.String(500), nullable=True),
|
||||||
|
sa.Column("changes", JSONB, nullable=True),
|
||||||
|
sa.Column("meta", JSONB, nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_audit_actor", "audit_log", ["actor_id", "occurred_at"])
|
||||||
|
op.create_index("idx_audit_entity", "audit_log", ["entity_type","entity_id", "occurred_at"])
|
||||||
|
op.create_index("idx_audit_action", "audit_log", ["action", "occurred_at"])
|
||||||
|
op.create_index("idx_audit_occurred", "audit_log", ["occurred_at"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# staff
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"staff",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||||
|
sa.Column("email", sa.String(256), nullable=False, unique=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(64), nullable=False, server_default="staff"),
|
||||||
|
sa.Column("permissions", JSONB, nullable=False, server_default="{}"),
|
||||||
|
sa.Column("hashed_password", sa.String(256), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# console_settings & public_features
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"console_settings",
|
||||||
|
sa.Column("key", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("value", JSONB, nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"public_features",
|
||||||
|
sa.Column("key", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("value", JSONB, nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_products
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_products",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||||
|
sa.Column("name", sa.String(500), nullable=False),
|
||||||
|
sa.Column("sku", sa.String(128), nullable=True),
|
||||||
|
sa.Column("category", sa.String(128), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("unit_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("currency", sa.String(10), nullable=False, server_default="EUR"),
|
||||||
|
sa.Column("unit_type", sa.String(32), nullable=False, server_default="pcs"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_customers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_customers",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("firestore_id", sa.String(128), nullable=True, unique=True),
|
||||||
|
sa.Column("title", sa.String(32), nullable=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("surname", sa.String(255), nullable=True),
|
||||||
|
sa.Column("organization", sa.String(500), nullable=True),
|
||||||
|
sa.Column("religion", sa.String(64), nullable=True),
|
||||||
|
sa.Column("language", sa.String(10), nullable=False, server_default="el"),
|
||||||
|
sa.Column("folder_id", sa.String(128), nullable=False, unique=True),
|
||||||
|
sa.Column("relationship_status", sa.String(64), nullable=False, server_default="lead"),
|
||||||
|
sa.Column("nextcloud_folder", sa.String(500), nullable=True),
|
||||||
|
sa.Column("contacts", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("notes", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("location", JSONB, nullable=True),
|
||||||
|
sa.Column("tags", ARRAY(sa.String()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("owned_items", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("linked_user_ids", ARRAY(sa.String()), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("technical_issues", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("install_support", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("transaction_history", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("crm_summary", JSONB, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_customers_rel_status", "crm_customers", ["relationship_status"])
|
||||||
|
op.create_index("idx_crm_customers_name", "crm_customers", ["name", "surname"])
|
||||||
|
op.create_index("idx_crm_customers_tags", "crm_customers", ["tags"],
|
||||||
|
postgresql_using="gin")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_orders
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_orders",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("customer_id", sa.String(128),
|
||||||
|
sa.ForeignKey("crm_customers.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("order_number", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("title", sa.String(500), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(128), nullable=True),
|
||||||
|
sa.Column("status", sa.String(64), nullable=False,
|
||||||
|
server_default="negotiating"),
|
||||||
|
sa.Column("status_updated_date", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status_updated_by", sa.String(128), nullable=True),
|
||||||
|
sa.Column("items", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("subtotal", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount", JSONB, nullable=True),
|
||||||
|
sa.Column("total_price", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("currency", sa.String(10), nullable=False, server_default="EUR"),
|
||||||
|
sa.Column("shipping", JSONB, nullable=True),
|
||||||
|
sa.Column("payment_status", JSONB, nullable=False, server_default="{}"),
|
||||||
|
sa.Column("invoice_path", sa.String(500), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("timeline", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_orders_customer", "crm_orders", ["customer_id"])
|
||||||
|
op.create_index("idx_crm_orders_status", "crm_orders", ["status"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_comms_log
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_comms_log",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("customer_id", sa.String(128),
|
||||||
|
sa.ForeignKey("crm_customers.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("type", sa.String(32), nullable=False),
|
||||||
|
sa.Column("mail_account", sa.String(256), nullable=True),
|
||||||
|
sa.Column("direction", sa.String(16), nullable=False),
|
||||||
|
sa.Column("subject", sa.String(500), nullable=True),
|
||||||
|
sa.Column("body", sa.Text(), nullable=True),
|
||||||
|
sa.Column("body_html", sa.Text(), nullable=True),
|
||||||
|
sa.Column("attachments", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("ext_message_id", sa.String(500), nullable=True),
|
||||||
|
sa.Column("from_addr", sa.String(500), nullable=True),
|
||||||
|
sa.Column("to_addrs", sa.Text(), nullable=True),
|
||||||
|
sa.Column("logged_by", sa.String(128), nullable=True),
|
||||||
|
sa.Column("is_important", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_comms_customer", "crm_comms_log", ["customer_id", "occurred_at"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_media
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_media",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("customer_id", sa.String(128),
|
||||||
|
sa.ForeignKey("crm_customers.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("order_id", sa.String(128), nullable=True),
|
||||||
|
sa.Column("filename", sa.String(500), nullable=False),
|
||||||
|
sa.Column("nextcloud_path", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("thumbnail_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("mime_type", sa.String(128), nullable=True),
|
||||||
|
sa.Column("direction", sa.String(16), nullable=True),
|
||||||
|
sa.Column("tags", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("uploaded_by", sa.String(128), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_media_customer", "crm_media", ["customer_id"])
|
||||||
|
op.create_index("idx_crm_media_order", "crm_media", ["order_id"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_sync_state
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_sync_state",
|
||||||
|
sa.Column("key", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("value", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_quotations
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_quotations",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("quotation_number", sa.String(64), nullable=False, unique=True),
|
||||||
|
sa.Column("title", sa.String(500), nullable=True),
|
||||||
|
sa.Column("subtitle", sa.String(500), nullable=True),
|
||||||
|
sa.Column("customer_id", sa.String(128),
|
||||||
|
sa.ForeignKey("crm_customers.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("language", sa.String(10), nullable=False, server_default="en"),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="draft"),
|
||||||
|
sa.Column("order_type", sa.String(64), nullable=True),
|
||||||
|
sa.Column("shipping_method", sa.String(64), nullable=True),
|
||||||
|
sa.Column("estimated_shipping_date", sa.String(32), nullable=True),
|
||||||
|
sa.Column("global_discount_label", sa.String(128), nullable=True),
|
||||||
|
sa.Column("global_discount_percent", sa.Numeric(8, 4), nullable=False, server_default="0"),
|
||||||
|
sa.Column("vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||||
|
sa.Column("global_vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||||
|
sa.Column("shipping_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("shipping_cost_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("install_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("install_cost_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("extras_label", sa.String(256), nullable=True),
|
||||||
|
sa.Column("extras_cost", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("comments", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("quick_notes", JSONB, nullable=False, server_default="{}"),
|
||||||
|
sa.Column("subtotal_before_discount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("global_discount_amount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("new_subtotal", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("vat_amount", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("final_total", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("nextcloud_pdf_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("nextcloud_pdf_url", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("client_org", sa.String(500), nullable=True),
|
||||||
|
sa.Column("client_name", sa.String(500), nullable=True),
|
||||||
|
sa.Column("client_location", sa.String(500), nullable=True),
|
||||||
|
sa.Column("client_phone", sa.String(64), nullable=True),
|
||||||
|
sa.Column("client_email", sa.String(256), nullable=True),
|
||||||
|
sa.Column("is_legacy", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("legacy_date", sa.String(32), nullable=True),
|
||||||
|
sa.Column("legacy_pdf_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_quotations_customer", "crm_quotations", ["customer_id"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# crm_quotation_items
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"crm_quotation_items",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("quotation_id", sa.String(128),
|
||||||
|
sa.ForeignKey("crm_quotations.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("product_id", sa.String(128), nullable=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("description_en", sa.Text(), nullable=True),
|
||||||
|
sa.Column("description_gr", sa.Text(), nullable=True),
|
||||||
|
sa.Column("unit_type", sa.String(32), nullable=False, server_default="pcs"),
|
||||||
|
sa.Column("unit_cost", sa.Numeric(12, 4), nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_percent", sa.Numeric(8, 4), nullable=False, server_default="0"),
|
||||||
|
sa.Column("vat_percent", sa.Numeric(8, 4), nullable=False, server_default="24"),
|
||||||
|
sa.Column("quantity", sa.Numeric(12, 4), nullable=False, server_default="1"),
|
||||||
|
sa.Column("line_total", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_crm_quotation_items_quotation", "crm_quotation_items",
|
||||||
|
["quotation_id", "sort_order"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# melody_drafts
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"melody_drafts",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="draft"),
|
||||||
|
sa.Column("data", JSONB, nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("idx_melody_drafts_status", "melody_drafts", ["status"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# built_melodies
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"built_melodies",
|
||||||
|
sa.Column("id", sa.String(128), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(500), nullable=False),
|
||||||
|
sa.Column("pid", sa.String(128), nullable=False),
|
||||||
|
sa.Column("steps", JSONB, nullable=False),
|
||||||
|
sa.Column("binary_path", sa.String(1000), nullable=True),
|
||||||
|
sa.Column("progmem_code", sa.Text(), nullable=True),
|
||||||
|
sa.Column("assigned_melody_ids", JSONB, nullable=False, server_default="[]"),
|
||||||
|
sa.Column("is_builtin", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# mfg_audit_log
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"mfg_audit_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.Column("admin_user", sa.String(256), nullable=False),
|
||||||
|
sa.Column("action", sa.String(128), nullable=False),
|
||||||
|
sa.Column("serial_number", sa.String(128), nullable=True),
|
||||||
|
sa.Column("detail", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_mfg_audit_time", "mfg_audit_log", ["timestamp"])
|
||||||
|
op.create_index("idx_mfg_audit_action", "mfg_audit_log", ["action"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# device_alerts
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.create_table(
|
||||||
|
"device_alerts",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("device_serial", sa.String(128), nullable=False),
|
||||||
|
sa.Column("subsystem", sa.String(128), nullable=False),
|
||||||
|
sa.Column("state", sa.String(64), nullable=False),
|
||||||
|
sa.Column("message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("device_serial", "subsystem", name="uq_device_alerts_serial_subsystem"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_device_alerts_serial", "device_alerts", ["device_serial"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# commands (raw SQL — mirrors SQLite schema, no ORM model)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE commands (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
device_serial TEXT NOT NULL,
|
||||||
|
command_name TEXT NOT NULL,
|
||||||
|
command_payload TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
response_payload TEXT,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
responded_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX idx_commands_serial_time ON commands(device_serial, sent_at DESC)")
|
||||||
|
op.execute("CREATE INDEX idx_commands_status ON commands(status)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# heartbeats (raw SQL — mirrors SQLite schema, no ORM model)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE heartbeats (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
device_serial TEXT NOT NULL,
|
||||||
|
device_id TEXT,
|
||||||
|
firmware_version TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
gateway TEXT,
|
||||||
|
uptime_ms BIGINT,
|
||||||
|
uptime_display TEXT,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("CREATE INDEX idx_heartbeats_serial_time ON heartbeats(device_serial, received_at DESC)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# device_logs — partitioned by month on received_at
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE device_logs (
|
||||||
|
id BIGSERIAL,
|
||||||
|
device_serial TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
device_timestamp BIGINT,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (id, received_at)
|
||||||
|
) PARTITION BY RANGE (received_at)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX idx_device_logs_serial_time
|
||||||
|
ON device_logs(device_serial, received_at DESC)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX idx_device_logs_level
|
||||||
|
ON device_logs(level, received_at DESC)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create partitions: 2025-01 through 2026-06 (covers all existing data + near future)
|
||||||
|
partitions = [
|
||||||
|
("2025_01", "2025-01-01", "2025-02-01"),
|
||||||
|
("2025_02", "2025-02-01", "2025-03-01"),
|
||||||
|
("2025_03", "2025-03-01", "2025-04-01"),
|
||||||
|
("2025_04", "2025-04-01", "2025-05-01"),
|
||||||
|
("2025_05", "2025-05-01", "2025-06-01"),
|
||||||
|
("2025_06", "2025-06-01", "2025-07-01"),
|
||||||
|
("2025_07", "2025-07-01", "2025-08-01"),
|
||||||
|
("2025_08", "2025-08-01", "2025-09-01"),
|
||||||
|
("2025_09", "2025-09-01", "2025-10-01"),
|
||||||
|
("2025_10", "2025-10-01", "2025-11-01"),
|
||||||
|
("2025_11", "2025-11-01", "2025-12-01"),
|
||||||
|
("2025_12", "2025-12-01", "2026-01-01"),
|
||||||
|
("2026_01", "2026-01-01", "2026-02-01"),
|
||||||
|
("2026_02", "2026-02-01", "2026-03-01"),
|
||||||
|
("2026_03", "2026-03-01", "2026-04-01"),
|
||||||
|
("2026_04", "2026-04-01", "2026-05-01"),
|
||||||
|
("2026_05", "2026-05-01", "2026-06-01"),
|
||||||
|
("2026_06", "2026-06-01", "2026-07-01"),
|
||||||
|
]
|
||||||
|
for suffix, start, end in partitions:
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE TABLE device_logs_{suffix} PARTITION OF device_logs
|
||||||
|
FOR VALUES FROM ('{start}') TO ('{end}')
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop in reverse dependency order
|
||||||
|
op.execute("DROP TABLE IF EXISTS device_logs CASCADE") # drops all partitions too
|
||||||
|
op.execute("DROP TABLE IF EXISTS heartbeats CASCADE")
|
||||||
|
op.execute("DROP TABLE IF EXISTS commands CASCADE")
|
||||||
|
op.drop_table("device_alerts")
|
||||||
|
op.drop_table("mfg_audit_log")
|
||||||
|
op.drop_table("built_melodies")
|
||||||
|
op.drop_table("melody_drafts")
|
||||||
|
op.drop_table("crm_quotation_items")
|
||||||
|
op.drop_table("crm_quotations")
|
||||||
|
op.drop_table("crm_sync_state")
|
||||||
|
op.drop_table("crm_media")
|
||||||
|
op.drop_table("crm_comms_log")
|
||||||
|
op.drop_table("crm_orders")
|
||||||
|
op.drop_table("crm_customers")
|
||||||
|
op.drop_table("crm_products")
|
||||||
|
op.drop_table("public_features")
|
||||||
|
op.drop_table("console_settings")
|
||||||
|
op.drop_table("staff")
|
||||||
|
op.drop_table("audit_log")
|
||||||
|
op.drop_table("_migration_runs")
|
||||||
@@ -26,6 +26,9 @@ class Settings(BaseSettings):
|
|||||||
sqlite_db_path: str = "./data/database.db"
|
sqlite_db_path: str = "./data/database.db"
|
||||||
mqtt_data_retention_days: int = 90
|
mqtt_data_retention_days: int = 90
|
||||||
|
|
||||||
|
# Postgres
|
||||||
|
database_url: str = "postgresql+asyncpg://bellsystems_user:password@postgres:5432/bellsystems_db"
|
||||||
|
|
||||||
# Local file storage
|
# Local file storage
|
||||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||||
firmware_storage_path: str = "./storage/firmware"
|
firmware_storage_path: str = "./storage/firmware"
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ class MailListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/latest-batch", response_model=dict)
|
||||||
|
async def latest_comm_batch(
|
||||||
|
ids: str = Query(..., description="Comma-separated customer IDs"),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
"""Return the latest comm summary (id, type, occurred_at) keyed by customer_id."""
|
||||||
|
customer_ids = [i.strip() for i in ids.split(",") if i.strip()]
|
||||||
|
return await service.get_latest_comm_batch(customer_ids)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/all", response_model=CommListResponse)
|
@router.get("/all", response_model=CommListResponse)
|
||||||
async def list_all_comms(
|
async def list_all_comms(
|
||||||
type: Optional[str] = Query(None),
|
type: Optional[str] = Query(None),
|
||||||
|
|||||||
239
backend/crm/orm.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer,
|
||||||
|
Numeric, String, Text, UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class CrmProduct(Base):
|
||||||
|
__tablename__ = "crm_products"
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||||
|
firestore_id = Column(String(128), unique=True) # same as id during transition
|
||||||
|
name = Column(String(500), nullable=False)
|
||||||
|
sku = Column(String(128))
|
||||||
|
category = Column(String(128))
|
||||||
|
description = Column(Text)
|
||||||
|
unit_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
currency = Column(String(10), nullable=False, default="EUR")
|
||||||
|
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class CrmCustomer(Base):
|
||||||
|
__tablename__ = "crm_customers"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_customers_rel_status", "relationship_status"),
|
||||||
|
Index("idx_crm_customers_name", "name", "surname"),
|
||||||
|
Index("idx_crm_customers_tags", "tags", postgresql_using="gin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||||
|
firestore_id = Column(String(128), unique=True)
|
||||||
|
title = Column(String(32))
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
surname = Column(String(255))
|
||||||
|
organization = Column(String(500))
|
||||||
|
religion = Column(String(64))
|
||||||
|
language = Column(String(10), nullable=False, default="el")
|
||||||
|
folder_id = Column(String(128), unique=True, nullable=False)
|
||||||
|
relationship_status = Column(String(64), nullable=False, default="lead")
|
||||||
|
nextcloud_folder = Column(String(500))
|
||||||
|
contacts = Column(JSONB, nullable=False, default=list)
|
||||||
|
notes = Column(JSONB, nullable=False, default=list)
|
||||||
|
location = Column(JSONB)
|
||||||
|
tags = Column(ARRAY(String), nullable=False, default=list)
|
||||||
|
owned_items = Column(JSONB, nullable=False, default=list)
|
||||||
|
linked_user_ids = Column(ARRAY(String), nullable=False, default=list)
|
||||||
|
technical_issues = Column(JSONB, nullable=False, default=list)
|
||||||
|
install_support = Column(JSONB, nullable=False, default=list)
|
||||||
|
transaction_history = Column(JSONB, nullable=False, default=list)
|
||||||
|
crm_summary = Column(JSONB)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
orders = relationship("CrmOrder", back_populates="customer",
|
||||||
|
cascade="all, delete-orphan", lazy="noload")
|
||||||
|
quotations = relationship("CrmQuotation", back_populates="customer",
|
||||||
|
cascade="all, delete-orphan", lazy="noload")
|
||||||
|
comms = relationship("CrmCommsLog", back_populates="customer",
|
||||||
|
cascade="all, delete-orphan", lazy="noload")
|
||||||
|
media = relationship("CrmMedia", back_populates="customer", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmOrder(Base):
|
||||||
|
__tablename__ = "crm_orders"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_orders_customer", "customer_id"),
|
||||||
|
Index("idx_crm_orders_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True) # Firestore doc ID
|
||||||
|
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
order_number = Column(String(64), unique=True, nullable=False)
|
||||||
|
title = Column(String(500))
|
||||||
|
created_by = Column(String(128))
|
||||||
|
status = Column(String(64), nullable=False, default="negotiating")
|
||||||
|
status_updated_date = Column(DateTime(timezone=True))
|
||||||
|
status_updated_by = Column(String(128))
|
||||||
|
items = Column(JSONB, nullable=False, default=list)
|
||||||
|
subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
discount = Column(JSONB)
|
||||||
|
total_price = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
currency = Column(String(10), nullable=False, default="EUR")
|
||||||
|
shipping = Column(JSONB)
|
||||||
|
payment_status = Column(JSONB, nullable=False, default=dict)
|
||||||
|
invoice_path = Column(String(500))
|
||||||
|
notes = Column(Text)
|
||||||
|
timeline = Column(JSONB, nullable=False, default=list)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
customer = relationship("CrmCustomer", back_populates="orders")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmCommsLog(Base):
|
||||||
|
__tablename__ = "crm_comms_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_comms_customer", "customer_id", "occurred_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||||
|
nullable=True)
|
||||||
|
type = Column(String(32), nullable=False) # email | sms | call | note | ...
|
||||||
|
mail_account = Column(String(256))
|
||||||
|
direction = Column(String(16), nullable=False) # inbound | outbound
|
||||||
|
subject = Column(String(500))
|
||||||
|
body = Column(Text)
|
||||||
|
body_html = Column(Text)
|
||||||
|
attachments = Column(JSONB, nullable=False, default=list)
|
||||||
|
ext_message_id = Column(String(500))
|
||||||
|
from_addr = Column(String(500))
|
||||||
|
to_addrs = Column(Text) # JSON array as text or comma-sep
|
||||||
|
logged_by = Column(String(128))
|
||||||
|
is_important = Column(Boolean, nullable=False, default=False)
|
||||||
|
is_read = Column(Boolean, nullable=False, default=True)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
|
||||||
|
customer = relationship("CrmCustomer", back_populates="comms")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmMedia(Base):
|
||||||
|
__tablename__ = "crm_media"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_media_customer", "customer_id"),
|
||||||
|
Index("idx_crm_media_order", "order_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="SET NULL"),
|
||||||
|
nullable=True)
|
||||||
|
order_id = Column(String(128))
|
||||||
|
filename = Column(String(500), nullable=False)
|
||||||
|
nextcloud_path = Column(String(1000), nullable=False)
|
||||||
|
thumbnail_path = Column(String(1000))
|
||||||
|
mime_type = Column(String(128))
|
||||||
|
direction = Column(String(16))
|
||||||
|
tags = Column(JSONB, nullable=False, default=list)
|
||||||
|
uploaded_by = Column(String(128))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
|
||||||
|
customer = relationship("CrmCustomer", back_populates="media")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmSyncState(Base):
|
||||||
|
__tablename__ = "crm_sync_state"
|
||||||
|
|
||||||
|
key = Column(String(128), primary_key=True)
|
||||||
|
value = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class CrmQuotation(Base):
|
||||||
|
__tablename__ = "crm_quotations"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_quotations_customer", "customer_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
quotation_number = Column(String(64), unique=True, nullable=False)
|
||||||
|
title = Column(String(500))
|
||||||
|
subtitle = Column(String(500))
|
||||||
|
customer_id = Column(String(128), ForeignKey("crm_customers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
language = Column(String(10), nullable=False, default="en")
|
||||||
|
status = Column(String(32), nullable=False, default="draft")
|
||||||
|
order_type = Column(String(64))
|
||||||
|
shipping_method = Column(String(64))
|
||||||
|
estimated_shipping_date = Column(String(32)) # stored as DATE string
|
||||||
|
global_discount_label = Column(String(128))
|
||||||
|
global_discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||||
|
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||||
|
global_vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||||
|
shipping_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
shipping_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
install_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
install_cost_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
extras_label = Column(String(256))
|
||||||
|
extras_cost = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
comments = Column(JSONB, nullable=False, default=list)
|
||||||
|
quick_notes = Column(JSONB, nullable=False, default=dict)
|
||||||
|
subtotal_before_discount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
global_discount_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
new_subtotal = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
vat_amount = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
final_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
nextcloud_pdf_path = Column(String(1000))
|
||||||
|
nextcloud_pdf_url = Column(String(1000))
|
||||||
|
# Client snapshot fields (denormalised for PDF generation)
|
||||||
|
client_org = Column(String(500))
|
||||||
|
client_name = Column(String(500))
|
||||||
|
client_location = Column(String(500))
|
||||||
|
client_phone = Column(String(64))
|
||||||
|
client_email = Column(String(256))
|
||||||
|
# Legacy quotation fields
|
||||||
|
is_legacy = Column(Boolean, nullable=False, default=False)
|
||||||
|
legacy_date = Column(String(32))
|
||||||
|
legacy_pdf_path = Column(String(1000))
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
customer = relationship("CrmCustomer", back_populates="quotations")
|
||||||
|
items = relationship("CrmQuotationItem", back_populates="quotation",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="CrmQuotationItem.sort_order", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class CrmQuotationItem(Base):
|
||||||
|
__tablename__ = "crm_quotation_items"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_crm_quotation_items_quotation", "quotation_id", "sort_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
quotation_id = Column(String(128), ForeignKey("crm_quotations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
product_id = Column(String(128))
|
||||||
|
description = Column(Text)
|
||||||
|
description_en = Column(Text)
|
||||||
|
description_gr = Column(Text)
|
||||||
|
unit_type = Column(String(32), nullable=False, default="pcs")
|
||||||
|
unit_cost = Column(Numeric(12, 4), nullable=False, default=0)
|
||||||
|
discount_percent = Column(Numeric(8, 4), nullable=False, default=0)
|
||||||
|
vat_percent = Column(Numeric(8, 4), nullable=False, default=24)
|
||||||
|
quantity = Column(Numeric(12, 4), nullable=False, default=1)
|
||||||
|
line_total = Column(Numeric(12, 2), nullable=False, default=0)
|
||||||
|
sort_order = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
quotation = relationship("CrmQuotation", back_populates="items")
|
||||||
@@ -5,9 +5,10 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class QuotationStatus(str, Enum):
|
class QuotationStatus(str, Enum):
|
||||||
draft = "draft"
|
draft = "draft"
|
||||||
|
built = "built"
|
||||||
sent = "sent"
|
sent = "sent"
|
||||||
accepted = "accepted"
|
accepted = "accepted"
|
||||||
rejected = "rejected"
|
declined = "declined"
|
||||||
|
|
||||||
|
|
||||||
class QuotationItemCreate(BaseModel):
|
class QuotationItemCreate(BaseModel):
|
||||||
@@ -39,6 +40,7 @@ class QuotationCreate(BaseModel):
|
|||||||
estimated_shipping_date: Optional[str] = None
|
estimated_shipping_date: Optional[str] = None
|
||||||
global_discount_label: Optional[str] = None
|
global_discount_label: Optional[str] = None
|
||||||
global_discount_percent: float = 0.0
|
global_discount_percent: float = 0.0
|
||||||
|
global_vat_percent: float = 24.0
|
||||||
shipping_cost: float = 0.0
|
shipping_cost: float = 0.0
|
||||||
shipping_cost_discount: float = 0.0
|
shipping_cost_discount: float = 0.0
|
||||||
install_cost: float = 0.0
|
install_cost: float = 0.0
|
||||||
@@ -70,6 +72,7 @@ class QuotationUpdate(BaseModel):
|
|||||||
estimated_shipping_date: Optional[str] = None
|
estimated_shipping_date: Optional[str] = None
|
||||||
global_discount_label: Optional[str] = None
|
global_discount_label: Optional[str] = None
|
||||||
global_discount_percent: Optional[float] = None
|
global_discount_percent: Optional[float] = None
|
||||||
|
global_vat_percent: Optional[float] = None
|
||||||
shipping_cost: Optional[float] = None
|
shipping_cost: Optional[float] = None
|
||||||
shipping_cost_discount: Optional[float] = None
|
shipping_cost_discount: Optional[float] = None
|
||||||
install_cost: Optional[float] = None
|
install_cost: Optional[float] = None
|
||||||
@@ -104,6 +107,7 @@ class QuotationInDB(BaseModel):
|
|||||||
estimated_shipping_date: Optional[str] = None
|
estimated_shipping_date: Optional[str] = None
|
||||||
global_discount_label: Optional[str] = None
|
global_discount_label: Optional[str] = None
|
||||||
global_discount_percent: float = 0.0
|
global_discount_percent: float = 0.0
|
||||||
|
global_vat_percent: float = 24.0
|
||||||
shipping_cost: float = 0.0
|
shipping_cost: float = 0.0
|
||||||
shipping_cost_discount: float = 0.0
|
shipping_cost_discount: float = 0.0
|
||||||
install_cost: float = 0.0
|
install_cost: float = 0.0
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ def _float(d: Decimal) -> float:
|
|||||||
def _calculate_totals(
|
def _calculate_totals(
|
||||||
items: list,
|
items: list,
|
||||||
global_discount_percent: float,
|
global_discount_percent: float,
|
||||||
|
global_vat_percent: float,
|
||||||
shipping_cost: float,
|
shipping_cost: float,
|
||||||
shipping_cost_discount: float,
|
shipping_cost_discount: float,
|
||||||
install_cost: float,
|
install_cost: float,
|
||||||
@@ -50,21 +51,20 @@ def _calculate_totals(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
Calculate all monetary totals using Decimal arithmetic (ROUND_HALF_UP).
|
||||||
VAT is computed per-item from each item's vat_percent field.
|
VAT is a single global rate applied to items only (not shipping or install).
|
||||||
Shipping and install costs carry 0% VAT.
|
Shipping and install costs carry 0% VAT.
|
||||||
Returns a dict of floats ready for DB storage.
|
Returns a dict of floats ready for DB storage.
|
||||||
"""
|
"""
|
||||||
# Per-line totals and per-item VAT
|
# Per-line totals (items only)
|
||||||
item_totals = []
|
item_totals = []
|
||||||
item_vat = Decimal(0)
|
|
||||||
for item in items:
|
for item in items:
|
||||||
cost = _d(item.get("unit_cost", 0))
|
cost = _d(item.get("unit_cost", 0))
|
||||||
qty = _d(item.get("quantity", 1))
|
qty = _d(item.get("quantity", 1))
|
||||||
disc = _d(item.get("discount_percent", 0))
|
disc = _d(item.get("discount_percent", 0))
|
||||||
net = cost * qty * (1 - disc / 100)
|
net = cost * qty * (1 - disc / 100)
|
||||||
item_totals.append(net)
|
item_totals.append(net)
|
||||||
vat_pct = _d(item.get("vat_percent", 24))
|
|
||||||
item_vat += net * (vat_pct / 100)
|
items_net = sum(item_totals, Decimal(0))
|
||||||
|
|
||||||
# Shipping net (VAT = 0%)
|
# Shipping net (VAT = 0%)
|
||||||
ship_gross = _d(shipping_cost)
|
ship_gross = _d(shipping_cost)
|
||||||
@@ -76,16 +76,17 @@ def _calculate_totals(
|
|||||||
install_disc = _d(install_cost_discount)
|
install_disc = _d(install_cost_discount)
|
||||||
install_net = install_gross * (1 - install_disc / 100)
|
install_net = install_gross * (1 - install_disc / 100)
|
||||||
|
|
||||||
subtotal = sum(item_totals, Decimal(0)) + ship_net + install_net
|
subtotal = items_net + ship_net + install_net
|
||||||
|
|
||||||
global_disc_pct = _d(global_discount_percent)
|
global_disc_pct = _d(global_discount_percent)
|
||||||
global_disc_amount = subtotal * (global_disc_pct / 100)
|
global_disc_amount = subtotal * (global_disc_pct / 100)
|
||||||
new_subtotal = subtotal - global_disc_amount
|
new_subtotal = subtotal - global_disc_amount
|
||||||
|
|
||||||
# Global discount proportionally reduces VAT too
|
# VAT applies only to items portion, scaled by the global discount ratio
|
||||||
if subtotal > 0:
|
vat_pct = _d(global_vat_percent)
|
||||||
disc_ratio = new_subtotal / subtotal
|
if subtotal > 0 and items_net > 0:
|
||||||
vat_amount = item_vat * disc_ratio
|
items_ratio = items_net / subtotal
|
||||||
|
vat_amount = new_subtotal * items_ratio * (vat_pct / 100)
|
||||||
else:
|
else:
|
||||||
vat_amount = Decimal(0)
|
vat_amount = Decimal(0)
|
||||||
|
|
||||||
@@ -109,14 +110,16 @@ def _calc_line_total(item) -> float:
|
|||||||
|
|
||||||
|
|
||||||
async def _generate_quotation_number(db) -> str:
|
async def _generate_quotation_number(db) -> str:
|
||||||
year = datetime.utcnow().year
|
now = datetime.utcnow()
|
||||||
prefix = f"QT-{year}-"
|
yy = now.strftime("%y")
|
||||||
|
mm = now.strftime("%m")
|
||||||
|
prefix = f"QT-{yy}-{mm}-"
|
||||||
rows = await db.execute_fetchall(
|
rows = await db.execute_fetchall(
|
||||||
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
"SELECT quotation_number FROM crm_quotations WHERE quotation_number LIKE ? ORDER BY quotation_number DESC LIMIT 1",
|
||||||
(f"{prefix}%",),
|
(f"{prefix}%",),
|
||||||
)
|
)
|
||||||
if rows:
|
if rows:
|
||||||
last_num = rows[0][0] # e.g. "QT-2026-012"
|
last_num = rows[0][0] # e.g. "QT-26-04-012"
|
||||||
try:
|
try:
|
||||||
seq = int(last_num[len(prefix):]) + 1
|
seq = int(last_num[len(prefix):]) + 1
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -174,13 +177,16 @@ async def list_all_quotations() -> list[dict]:
|
|||||||
doc = fstore.collection("crm_customers").document(cid).get()
|
doc = fstore.collection("crm_customers").document(cid).get()
|
||||||
if doc.exists:
|
if doc.exists:
|
||||||
d = doc.to_dict()
|
d = doc.to_dict()
|
||||||
parts = [d.get("name", ""), d.get("surname", ""), d.get("organization", "")]
|
name_parts = [d.get("name", ""), d.get("surname", "")]
|
||||||
label = " ".join(p for p in parts if p).strip()
|
full_name = " ".join(p for p in name_parts if p).strip()
|
||||||
customer_names[cid] = label or cid
|
org = (d.get("organization", "") or "").strip()
|
||||||
|
customer_names[cid] = {"name": full_name or cid, "org": org}
|
||||||
except Exception:
|
except Exception:
|
||||||
customer_names[cid] = cid
|
customer_names[cid] = {"name": cid, "org": ""}
|
||||||
for item in items:
|
for item in items:
|
||||||
item["customer_name"] = customer_names.get(item["customer_id"], "")
|
info = customer_names.get(item["customer_id"], {"name": "", "org": ""})
|
||||||
|
item["customer_name"] = info["name"]
|
||||||
|
item["customer_org"] = info["org"]
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -222,6 +228,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
totals = _calculate_totals(
|
totals = _calculate_totals(
|
||||||
items_raw,
|
items_raw,
|
||||||
data.global_discount_percent,
|
data.global_discount_percent,
|
||||||
|
data.global_vat_percent,
|
||||||
data.shipping_cost,
|
data.shipping_cost,
|
||||||
data.shipping_cost_discount,
|
data.shipping_cost_discount,
|
||||||
data.install_cost,
|
data.install_cost,
|
||||||
@@ -236,7 +243,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
"""INSERT INTO crm_quotations (
|
"""INSERT INTO crm_quotations (
|
||||||
id, quotation_number, title, subtitle, customer_id,
|
id, quotation_number, title, subtitle, customer_id,
|
||||||
language, status, order_type, shipping_method, estimated_shipping_date,
|
language, status, order_type, shipping_method, estimated_shipping_date,
|
||||||
global_discount_label, global_discount_percent,
|
global_discount_label, global_discount_percent, global_vat_percent,
|
||||||
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
shipping_cost, shipping_cost_discount, install_cost, install_cost_discount,
|
||||||
extras_label, extras_cost, comments, quick_notes,
|
extras_label, extras_cost, comments, quick_notes,
|
||||||
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
subtotal_before_discount, global_discount_amount, new_subtotal, vat_amount, final_total,
|
||||||
@@ -247,7 +254,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
?, 'draft', ?, ?, ?,
|
?, 'draft', ?, ?, ?,
|
||||||
?, ?,
|
?, ?, ?,
|
||||||
?, ?, ?, ?,
|
?, ?, ?, ?,
|
||||||
?, ?, ?, ?,
|
?, ?, ?, ?,
|
||||||
?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?,
|
||||||
@@ -259,7 +266,7 @@ async def create_quotation(data: QuotationCreate, generate_pdf: bool = False) ->
|
|||||||
(
|
(
|
||||||
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
qid, quotation_number, data.title, data.subtitle, data.customer_id,
|
||||||
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
data.language, data.order_type, data.shipping_method, data.estimated_shipping_date,
|
||||||
data.global_discount_label, data.global_discount_percent,
|
data.global_discount_label, data.global_discount_percent, data.global_vat_percent,
|
||||||
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
data.shipping_cost, data.shipping_cost_discount, data.install_cost, data.install_cost_discount,
|
||||||
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
data.extras_label, data.extras_cost, comments_json, quick_notes_json,
|
||||||
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
totals["subtotal_before_discount"], totals["global_discount_amount"],
|
||||||
@@ -317,7 +324,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
|||||||
|
|
||||||
scalar_fields = [
|
scalar_fields = [
|
||||||
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
"title", "subtitle", "language", "status", "order_type", "shipping_method",
|
||||||
"estimated_shipping_date", "global_discount_label", "global_discount_percent",
|
"estimated_shipping_date", "global_discount_label", "global_discount_percent", "global_vat_percent",
|
||||||
"shipping_cost", "shipping_cost_discount", "install_cost",
|
"shipping_cost", "shipping_cost_discount", "install_cost",
|
||||||
"install_cost_discount", "extras_label", "extras_cost",
|
"install_cost_discount", "extras_label", "extras_cost",
|
||||||
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
"client_org", "client_name", "client_location", "client_phone", "client_email",
|
||||||
@@ -352,6 +359,7 @@ async def update_quotation(quotation_id: str, data: QuotationUpdate, generate_pd
|
|||||||
totals = _calculate_totals(
|
totals = _calculate_totals(
|
||||||
items_raw,
|
items_raw,
|
||||||
float(merged.get("global_discount_percent", 0)),
|
float(merged.get("global_discount_percent", 0)),
|
||||||
|
float(merged.get("global_vat_percent", 24)),
|
||||||
float(merged.get("shipping_cost", 0)),
|
float(merged.get("shipping_cost", 0)),
|
||||||
float(merged.get("shipping_cost_discount", 0)),
|
float(merged.get("shipping_cost_discount", 0)),
|
||||||
float(merged.get("install_cost", 0)),
|
float(merged.get("install_cost", 0)),
|
||||||
|
|||||||
@@ -305,6 +305,33 @@ async def get_last_comm_timestamp(customer_id: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_latest_comm_batch(customer_ids: list[str]) -> dict[str, dict]:
|
||||||
|
"""Return a dict of customer_id → {id, type, occurred_at} for the latest comm per customer.
|
||||||
|
Uses a single SQL query — no N+1 regardless of list size.
|
||||||
|
"""
|
||||||
|
if not customer_ids:
|
||||||
|
return {}
|
||||||
|
db = await mqtt_db.get_db()
|
||||||
|
placeholders = ",".join("?" * len(customer_ids))
|
||||||
|
rows = await db.execute_fetchall(
|
||||||
|
f"""
|
||||||
|
SELECT customer_id, id, type, COALESCE(occurred_at, created_at) AS ts
|
||||||
|
FROM crm_comms_log
|
||||||
|
WHERE customer_id IN ({placeholders})
|
||||||
|
AND customer_id IS NOT NULL AND customer_id != ''
|
||||||
|
ORDER BY ts DESC
|
||||||
|
""",
|
||||||
|
customer_ids,
|
||||||
|
)
|
||||||
|
# Keep only the first (latest) row per customer
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for row in rows:
|
||||||
|
cid = row[0]
|
||||||
|
if cid not in result:
|
||||||
|
result[cid] = {"id": row[1], "type": row[2], "occurred_at": row[3]}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def list_customers_sorted_by_latest_comm(customers: list[CustomerInDB]) -> list[CustomerInDB]:
|
async def list_customers_sorted_by_latest_comm(customers: list[CustomerInDB]) -> list[CustomerInDB]:
|
||||||
"""Re-sort a list of customers so those with the most recent comm come first."""
|
"""Re-sort a list of customers so those with the most recent comm come first."""
|
||||||
timestamps = await asyncio.gather(
|
timestamps = await asyncio.gather(
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ async def init_db():
|
|||||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
||||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
||||||
"ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE crm_quotations ADD COLUMN global_vat_percent REAL NOT NULL DEFAULT 24",
|
||||||
]
|
]
|
||||||
for m in _migrations:
|
for m in _migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
23
backend/database/models.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from database.postgres import Base # noqa: F401 — Base must be imported for Alembic autogenerate
|
||||||
|
|
||||||
|
# Import all ORM models here so Alembic autogenerate detects them.
|
||||||
|
# Add each new model file as it is created.
|
||||||
|
|
||||||
|
# --- Existing ---
|
||||||
|
from notes.orm import Entry, EntryLink # noqa: F401
|
||||||
|
from tickets.orm import SupportTicket, TicketMessage # noqa: F401
|
||||||
|
|
||||||
|
# --- Phase 0 ---
|
||||||
|
from shared.orm import MigrationRun, AuditLog # noqa: F401
|
||||||
|
from crm.orm import ( # noqa: F401
|
||||||
|
CrmProduct, CrmCustomer, CrmOrder,
|
||||||
|
CrmCommsLog, CrmMedia, CrmSyncState,
|
||||||
|
CrmQuotation, CrmQuotationItem,
|
||||||
|
)
|
||||||
|
from staff.orm import Staff # noqa: F401
|
||||||
|
from settings.orm import ConsoleSetting, PublicFeature # noqa: F401
|
||||||
|
from melodies.orm import MelodyDraft, BuiltMelody # noqa: F401
|
||||||
|
from manufacturing.orm import MfgAuditLog # noqa: F401
|
||||||
|
from devices.orm import DeviceAlert # noqa: F401
|
||||||
|
# NOTE: device_logs, commands, heartbeats are partitioned/raw-SQL tables —
|
||||||
|
# they are NOT ORM models and are created via op.execute() in the migration.
|
||||||
16
backend/database/postgres.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_size=10, echo=False)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pg_session() -> AsyncSession:
|
||||||
|
"""FastAPI dependency — yields a DB session and closes it after the request."""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
31
backend/devices/orm.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceAlert(Base):
|
||||||
|
"""Current alert state per device+subsystem (upserted, not appended)."""
|
||||||
|
__tablename__ = "device_alerts"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("device_serial", "subsystem"),
|
||||||
|
Index("idx_device_alerts_serial", "device_serial"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
device_serial = Column(String(128), nullable=False)
|
||||||
|
subsystem = Column(String(128), nullable=False)
|
||||||
|
state = Column(String(64), nullable=False)
|
||||||
|
message = Column(Text)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: device_logs, commands, and heartbeats are NOT declared as ORM models here.
|
||||||
|
# device_logs is a partitioned table — SQLAlchemy ORM does not support declarative
|
||||||
|
# partitioned tables cleanly. All three tables are created via raw SQL in the
|
||||||
|
# Alembic migration and accessed via raw queries in database/core.py (SQLite now)
|
||||||
|
# and will be accessed via raw async SQL after Phase 5 cutover.
|
||||||
@@ -451,11 +451,17 @@ async def remove_user_from_device(
|
|||||||
data = device_doc.to_dict() or {}
|
data = device_doc.to_dict() or {}
|
||||||
user_list = data.get("user_list", []) or []
|
user_list = data.get("user_list", []) or []
|
||||||
|
|
||||||
# Remove any entry that resolves to this user_id
|
from google.cloud.firestore_v1 import DocumentReference as DocRef
|
||||||
new_list = [
|
|
||||||
entry for entry in user_list
|
def resolves_to(entry, uid: str) -> bool:
|
||||||
if not (isinstance(entry, str) and entry.split("/")[-1] == user_id)
|
if isinstance(entry, DocRef):
|
||||||
]
|
return entry.id == uid
|
||||||
|
if isinstance(entry, str):
|
||||||
|
return entry.split("/")[-1] == uid
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove any entry that resolves to this user_id (handles both DocRef and string paths)
|
||||||
|
new_list = [entry for entry in user_list if not resolves_to(entry, user_id)]
|
||||||
device_ref.update({"user_list": new_list})
|
device_ref.update({"user_list": new_list})
|
||||||
|
|
||||||
return {"status": "removed", "user_id": user_id}
|
return {"status": "removed", "user_id": user_id}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from crm.media_router import router as crm_media_router
|
|||||||
from crm.nextcloud_router import router as crm_nextcloud_router
|
from crm.nextcloud_router import router as crm_nextcloud_router
|
||||||
from crm.quotations_router import router as crm_quotations_router
|
from crm.quotations_router import router as crm_quotations_router
|
||||||
from public.router import router as public_router
|
from public.router import router as public_router
|
||||||
|
from notes.router import router as notes_router
|
||||||
|
from tickets.router import router as tickets_router
|
||||||
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
||||||
from crm.mail_accounts import get_mail_accounts
|
from crm.mail_accounts import get_mail_accounts
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
@@ -70,6 +72,8 @@ app.include_router(crm_media_router)
|
|||||||
app.include_router(crm_nextcloud_router)
|
app.include_router(crm_nextcloud_router)
|
||||||
app.include_router(crm_quotations_router)
|
app.include_router(crm_quotations_router)
|
||||||
app.include_router(public_router)
|
app.include_router(public_router)
|
||||||
|
app.include_router(notes_router)
|
||||||
|
app.include_router(tickets_router)
|
||||||
|
|
||||||
|
|
||||||
async def nextcloud_keepalive_loop():
|
async def nextcloud_keepalive_loop():
|
||||||
|
|||||||
22
backend/manufacturing/orm.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class MfgAuditLog(Base):
|
||||||
|
__tablename__ = "mfg_audit_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_mfg_audit_time", "timestamp"),
|
||||||
|
Index("idx_mfg_audit_action", "action"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
timestamp = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
admin_user = Column(String(256), nullable=False)
|
||||||
|
action = Column(String(128), nullable=False)
|
||||||
|
serial_number = Column(String(128))
|
||||||
|
detail = Column(Text)
|
||||||
39
backend/melodies/orm.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, Index, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class MelodyDraft(Base):
|
||||||
|
__tablename__ = "melody_drafts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_melody_drafts_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
status = Column(String(32), nullable=False, default="draft")
|
||||||
|
# 'data' stores the full melody definition as JSON (was TEXT/JSON in SQLite)
|
||||||
|
data = Column(JSONB, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltMelody(Base):
|
||||||
|
__tablename__ = "built_melodies"
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True)
|
||||||
|
name = Column(String(500), nullable=False)
|
||||||
|
pid = Column(String(128), nullable=False)
|
||||||
|
# 'steps' is a JSON array of step definitions
|
||||||
|
steps = Column(JSONB, nullable=False)
|
||||||
|
binary_path = Column(String(1000))
|
||||||
|
progmem_code = Column(Text)
|
||||||
|
# JSON array of melody IDs this built melody is assigned to
|
||||||
|
assigned_melody_ids = Column(JSONB, nullable=False, default=list)
|
||||||
|
is_builtin = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
0
backend/notes/__init__.py
Normal file
100
backend/notes/models.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
VALID_TYPES = {"note", "issue"}
|
||||||
|
VALID_STATUSES = {"open", "researching", "resolved"}
|
||||||
|
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||||
|
VALID_CATEGORIES = {"technical", "install_support", "general"}
|
||||||
|
VALID_ENTITIES = {"device", "app_user", "customer"}
|
||||||
|
|
||||||
|
|
||||||
|
class EntryLinkIn(BaseModel):
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str
|
||||||
|
|
||||||
|
@field_validator("entity_type")
|
||||||
|
@classmethod
|
||||||
|
def check_entity_type(cls, v):
|
||||||
|
if v not in VALID_ENTITIES:
|
||||||
|
raise ValueError(f"entity_type must be one of {VALID_ENTITIES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EntryCreate(BaseModel):
|
||||||
|
type: str
|
||||||
|
title: str = Field(..., max_length=500)
|
||||||
|
body: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
severity: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
links: List[EntryLinkIn] = []
|
||||||
|
|
||||||
|
@field_validator("type")
|
||||||
|
@classmethod
|
||||||
|
def check_type(cls, v):
|
||||||
|
if v not in VALID_TYPES:
|
||||||
|
raise ValueError(f"type must be one of {VALID_TYPES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def check_status(cls, v):
|
||||||
|
if v is not None and v not in VALID_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("severity")
|
||||||
|
@classmethod
|
||||||
|
def check_severity(cls, v):
|
||||||
|
if v is not None and v not in VALID_SEVERITIES:
|
||||||
|
raise ValueError(f"severity must be one of {VALID_SEVERITIES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("category")
|
||||||
|
@classmethod
|
||||||
|
def check_category(cls, v):
|
||||||
|
if v is not None and v not in VALID_CATEGORIES:
|
||||||
|
raise ValueError(f"category must be one of {VALID_CATEGORIES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EntryUpdate(BaseModel):
|
||||||
|
title: Optional[str] = Field(None, max_length=500)
|
||||||
|
body: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
severity: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EntryLinkOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class EntryOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
body: Optional[str]
|
||||||
|
status: Optional[str]
|
||||||
|
severity: Optional[str]
|
||||||
|
category: Optional[str]
|
||||||
|
author_id: str
|
||||||
|
author_name: Optional[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
links: List[EntryLinkOut] = []
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class EntryListResponse(BaseModel):
|
||||||
|
data: List[EntryOut]
|
||||||
|
pagination: dict
|
||||||
|
|
||||||
|
|
||||||
|
class LinksReplaceIn(BaseModel):
|
||||||
|
links: List[EntryLinkIn]
|
||||||
42
backend/notes/orm.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Entry(Base):
|
||||||
|
__tablename__ = "crm_entries"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
type = Column(String(10), nullable=False) # 'note' | 'issue'
|
||||||
|
title = Column(String(500), nullable=False)
|
||||||
|
body = Column(Text, nullable=True)
|
||||||
|
status = Column(String(20), nullable=True) # null for notes; open/researching/resolved for issues
|
||||||
|
severity = Column(String(10), nullable=True) # null | low | medium | high | critical
|
||||||
|
category = Column(String(30), nullable=True) # null for notes; technical | install_support | general
|
||||||
|
author_id = Column(String(128), nullable=False) # staff user ID from JWT
|
||||||
|
author_name = Column(String(255), nullable=True) # denormalized for display
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
links = relationship("EntryLink", back_populates="entry", cascade="all, delete-orphan", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class EntryLink(Base):
|
||||||
|
__tablename__ = "crm_entry_links"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("entry_id", "entity_type", "entity_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
entity_type = Column(String(20), nullable=False) # 'device' | 'app_user' | 'customer'
|
||||||
|
entity_id = Column(String(128), nullable=False) # Firestore ID or Postgres UUID as string
|
||||||
|
|
||||||
|
entry = relationship("Entry", back_populates="links")
|
||||||
79
backend/notes/router.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from database.postgres import get_pg_session
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from notes import service
|
||||||
|
from notes.models import EntryCreate, EntryUpdate, EntryOut, EntryListResponse, LinksReplaceIn
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/notes", tags=["notes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=EntryListResponse)
|
||||||
|
async def list_entries(
|
||||||
|
type: str | None = Query(None),
|
||||||
|
status: str | None = Query(None),
|
||||||
|
severity: str | None = Query(None),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
rows, total = await service.list_entries(db, type, status, severity, category, page, limit)
|
||||||
|
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-entity/{entity_type}/{entity_id}", response_model=list[EntryOut])
|
||||||
|
async def list_by_entity(
|
||||||
|
entity_type: str, entity_id: str,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await service.list_entries_for_entity(db, entity_type, entity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entry_id}", response_model=EntryOut)
|
||||||
|
async def get_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await service.get_entry(db, entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=EntryOut, status_code=201)
|
||||||
|
async def create_entry(
|
||||||
|
body: EntryCreate,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||||
|
):
|
||||||
|
return await service.create_entry(db, body, _user.sub, _user.name or _user.email)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{entry_id}", response_model=EntryOut)
|
||||||
|
async def update_entry(
|
||||||
|
entry_id: UUID, body: EntryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.update_entry(db, entry_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{entry_id}/links", response_model=EntryOut)
|
||||||
|
async def replace_links(
|
||||||
|
entry_id: UUID, body: LinksReplaceIn,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.replace_links(db, entry_id, body.links)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entry_id}", status_code=204)
|
||||||
|
async def delete_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "delete")),
|
||||||
|
):
|
||||||
|
await service.delete_entry(db, entry_id)
|
||||||
93
backend/notes/service.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import uuid
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, delete, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from notes.orm import Entry, EntryLink
|
||||||
|
from notes.models import EntryCreate, EntryUpdate, EntryLinkIn
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
async def create_entry(db: AsyncSession, data: EntryCreate, author_id: str, author_name: str) -> Entry:
|
||||||
|
entry = Entry(
|
||||||
|
type=data.type,
|
||||||
|
title=data.title,
|
||||||
|
body=data.body,
|
||||||
|
status=data.status if data.type == "issue" else None,
|
||||||
|
severity=data.severity if data.type == "issue" else None,
|
||||||
|
category=data.category if data.type == "issue" else None,
|
||||||
|
author_id=author_id,
|
||||||
|
author_name=author_name,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush() # get the ID before inserting links
|
||||||
|
|
||||||
|
for link_in in data.links:
|
||||||
|
db.add(EntryLink(entry_id=entry.id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return await _get_entry_with_links(db, entry.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_entry(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||||
|
return await _get_entry_with_links(db, entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_entries(
|
||||||
|
db: AsyncSession,
|
||||||
|
type: str | None, status: str | None, severity: str | None, category: str | None,
|
||||||
|
page: int, limit: int,
|
||||||
|
) -> tuple[list[Entry], int]:
|
||||||
|
limit = min(100, max(1, limit))
|
||||||
|
offset = (max(1, page) - 1) * limit
|
||||||
|
|
||||||
|
q = select(Entry).options(selectinload(Entry.links))
|
||||||
|
if type: q = q.where(Entry.type == type)
|
||||||
|
if status: q = q.where(Entry.status == status)
|
||||||
|
if severity: q = q.where(Entry.severity == severity)
|
||||||
|
if category: q = q.where(Entry.category == category)
|
||||||
|
|
||||||
|
total_q = select(func.count()).select_from(q.subquery())
|
||||||
|
total = (await db.execute(total_q)).scalar()
|
||||||
|
rows = (await db.execute(q.order_by(Entry.created_at.desc()).limit(limit).offset(offset))).scalars().all()
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
async def update_entry(db: AsyncSession, entry_id: uuid.UUID, data: EntryUpdate) -> Entry:
|
||||||
|
entry = await _get_entry_with_links(db, entry_id)
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(entry, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return await _get_entry_with_links(db, entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_entry(db: AsyncSession, entry_id: uuid.UUID):
|
||||||
|
entry = await _get_entry_with_links(db, entry_id)
|
||||||
|
await db.delete(entry)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def replace_links(db: AsyncSession, entry_id: uuid.UUID, links: list[EntryLinkIn]) -> Entry:
|
||||||
|
await _get_entry_with_links(db, entry_id) # raises 404 if not found
|
||||||
|
await db.execute(delete(EntryLink).where(EntryLink.entry_id == entry_id))
|
||||||
|
for link_in in links:
|
||||||
|
db.add(EntryLink(entry_id=entry_id, entity_type=link_in.entity_type, entity_id=link_in.entity_id))
|
||||||
|
await db.commit()
|
||||||
|
return await _get_entry_with_links(db, entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_entries_for_entity(db: AsyncSession, entity_type: str, entity_id: str) -> list[Entry]:
|
||||||
|
link_sq = select(EntryLink.entry_id).where(
|
||||||
|
EntryLink.entity_type == entity_type,
|
||||||
|
EntryLink.entity_id == entity_id,
|
||||||
|
).subquery()
|
||||||
|
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id.in_(select(link_sq)))
|
||||||
|
return (await db.execute(q.order_by(Entry.created_at.desc()))).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_entry_with_links(db: AsyncSession, entry_id: uuid.UUID) -> Entry:
|
||||||
|
q = select(Entry).options(selectinload(Entry.links)).where(Entry.id == entry_id)
|
||||||
|
result = (await db.execute(q)).scalar_one_or_none()
|
||||||
|
if not result:
|
||||||
|
raise NotFoundError("Entry")
|
||||||
|
return result
|
||||||
@@ -15,3 +15,6 @@ weasyprint>=62.0
|
|||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
pdf2image>=1.17.0
|
pdf2image>=1.17.0
|
||||||
|
asyncpg==0.30.0
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
alembic==1.14.0
|
||||||
26
backend/settings/orm.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, DateTime, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleSetting(Base):
|
||||||
|
"""Key/value store for console configuration (replaces Firestore 'settings' doc)."""
|
||||||
|
__tablename__ = "console_settings"
|
||||||
|
|
||||||
|
key = Column(String(128), primary_key=True)
|
||||||
|
value = Column(JSONB) # any JSON value
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFeature(Base):
|
||||||
|
"""Public-facing feature flags and configuration (replaces Firestore 'public_features' doc)."""
|
||||||
|
__tablename__ = "public_features"
|
||||||
|
|
||||||
|
key = Column(String(128), primary_key=True)
|
||||||
|
value = Column(JSONB)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
@@ -18,3 +18,8 @@ class AuthorizationError(HTTPException):
|
|||||||
class NotFoundError(HTTPException):
|
class NotFoundError(HTTPException):
|
||||||
def __init__(self, resource: str = "Resource"):
|
def __init__(self, resource: str = "Resource"):
|
||||||
super().__init__(status_code=404, detail=f"{resource} not found")
|
super().__init__(status_code=404, detail=f"{resource} not found")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(HTTPException):
|
||||||
|
def __init__(self, detail: str = "Validation error"):
|
||||||
|
super().__init__(status_code=422, detail=detail)
|
||||||
|
|||||||
46
backend/shared/orm.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import BigInteger, Column, DateTime, Index, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationRun(Base):
|
||||||
|
"""Tracks every migration script execution — what ran, when, row counts, success/failure."""
|
||||||
|
__tablename__ = "_migration_runs"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
script_name = Column(String(256), nullable=False)
|
||||||
|
ran_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
source_rows = Column(BigInteger, nullable=False, default=0)
|
||||||
|
dest_rows = Column(BigInteger, nullable=False, default=0)
|
||||||
|
success = Column(String(8), nullable=False, default="ok") # 'ok' | 'error'
|
||||||
|
notes = Column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""Staff action audit trail — all create/update/delete/command events."""
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_audit_actor", "actor_id", "occurred_at"),
|
||||||
|
Index("idx_audit_entity", "entity_type", "entity_id", "occurred_at"),
|
||||||
|
Index("idx_audit_action", "action", "occurred_at"),
|
||||||
|
Index("idx_audit_occurred", "occurred_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
actor_id = Column(String(128), nullable=False)
|
||||||
|
actor_name = Column(String(255), nullable=False)
|
||||||
|
action = Column(String(64), nullable=False)
|
||||||
|
# CREATE | UPDATE | DELETE | COMMAND | PUBLISH | UNPUBLISH |
|
||||||
|
# LOGIN | LOGOUT | PERMISSION_CHANGE | STATUS_CHANGE
|
||||||
|
entity_type = Column(String(64), nullable=False)
|
||||||
|
# customer | order | device | melody | product | staff | ticket | note | quotation | ...
|
||||||
|
entity_id = Column(String(128), nullable=False)
|
||||||
|
entity_label = Column(String(500)) # denormalised human name
|
||||||
|
changes = Column(JSONB) # {"field": {"old": x, "new": y}} — null for CREATE/DELETE
|
||||||
|
meta = Column(JSONB) # extra context: ip_address, command_name, etc.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Any, Dict, Optional
|
||||||
from auth.models import StaffPermissions
|
from auth.models import StaffPermissions
|
||||||
|
|
||||||
|
|
||||||
@@ -35,3 +35,7 @@ class StaffResponse(BaseModel):
|
|||||||
class StaffListResponse(BaseModel):
|
class StaffListResponse(BaseModel):
|
||||||
staff: list[StaffResponse]
|
staff: list[StaffResponse]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PreferencesUpdate(BaseModel):
|
||||||
|
prefs: Dict[str, Any]
|
||||||
|
|||||||
23
backend/staff/orm.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Staff(Base):
|
||||||
|
__tablename__ = "staff"
|
||||||
|
|
||||||
|
id = Column(String(128), primary_key=True) # Firestore doc ID during transition
|
||||||
|
firestore_id = Column(String(128), unique=True) # same as id during transition
|
||||||
|
email = Column(String(256), unique=True, nullable=False)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
role = Column(String(64), nullable=False, default="staff")
|
||||||
|
permissions = Column(JSONB, nullable=False, default=dict)
|
||||||
|
hashed_password = Column(String(256), nullable=False)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from typing import Any
|
||||||
from auth.dependencies import get_current_user, require_staff_management
|
from auth.dependencies import get_current_user, require_staff_management
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from staff import service
|
from staff import service
|
||||||
from staff.models import (
|
from staff.models import (
|
||||||
StaffCreate, StaffUpdate, StaffPasswordUpdate,
|
StaffCreate, StaffUpdate, StaffPasswordUpdate,
|
||||||
StaffResponse, StaffListResponse,
|
StaffResponse, StaffListResponse,
|
||||||
|
PreferencesUpdate,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/staff", tags=["staff"])
|
router = APIRouter(prefix="/api/staff", tags=["staff"])
|
||||||
@@ -15,6 +17,22 @@ async def get_current_staff(current_user: TokenPayload = Depends(get_current_use
|
|||||||
return await service.get_staff_me(current_user.sub)
|
return await service.get_staff_me(current_user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/preferences", response_model=dict)
|
||||||
|
async def get_preferences(current_user: TokenPayload = Depends(get_current_user)):
|
||||||
|
"""Return all UI preferences for the current staff member."""
|
||||||
|
return await service.get_preferences(current_user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/preferences/{page_key}", response_model=dict)
|
||||||
|
async def update_preferences(
|
||||||
|
page_key: str,
|
||||||
|
body: PreferencesUpdate,
|
||||||
|
current_user: TokenPayload = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Merge preference keys for a specific page into the staff member's stored prefs."""
|
||||||
|
return await service.update_preferences(current_user.sub, page_key, body.prefs)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=StaffListResponse)
|
@router.get("", response_model=StaffListResponse)
|
||||||
async def list_staff(
|
async def list_staff(
|
||||||
search: str = Query(None),
|
search: str = Query(None),
|
||||||
|
|||||||
@@ -157,6 +157,33 @@ async def update_staff_password(staff_id: str, new_password: str, current_user_r
|
|||||||
return {"message": "Password updated successfully"}
|
return {"message": "Password updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_preferences(staff_id: str) -> dict:
|
||||||
|
"""Return the ui_prefs map for a staff member, defaulting to {} if not set."""
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection("admin_users").document(staff_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Staff member not found")
|
||||||
|
return doc.to_dict().get("ui_prefs", {})
|
||||||
|
|
||||||
|
|
||||||
|
async def update_preferences(staff_id: str, page_key: str, prefs: dict) -> dict:
|
||||||
|
"""Merge a page-level preferences dict into ui_prefs.<page_key> on the staff document.
|
||||||
|
Only the supplied keys are overwritten — other keys in the same page block survive.
|
||||||
|
"""
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection("admin_users").document(staff_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Staff member not found")
|
||||||
|
|
||||||
|
existing_prefs = doc.to_dict().get("ui_prefs", {})
|
||||||
|
page_prefs = {**existing_prefs.get(page_key, {}), **prefs}
|
||||||
|
existing_prefs[page_key] = page_prefs
|
||||||
|
|
||||||
|
doc_ref.update({"ui_prefs": existing_prefs})
|
||||||
|
return existing_prefs
|
||||||
|
|
||||||
|
|
||||||
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
|
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc_ref = db.collection("admin_users").document(staff_id)
|
doc_ref = db.collection("admin_users").document(staff_id)
|
||||||
|
|||||||
@@ -370,12 +370,11 @@
|
|||||||
{% set L_DISC = "Έκπτ." %}
|
{% set L_DISC = "Έκπτ." %}
|
||||||
{% set L_QTY = "Ποσ." %}
|
{% set L_QTY = "Ποσ." %}
|
||||||
{% set L_UNIT = "Μον." %}
|
{% set L_UNIT = "Μον." %}
|
||||||
{% set L_VAT_COL = "Φ.Π.Α." %}
|
|
||||||
{% set L_TOTAL = "Σύνολο" %}
|
{% set L_TOTAL = "Σύνολο" %}
|
||||||
{% set L_SUBTOTAL = "Υποσύνολο" %}
|
{% set L_SUBTOTAL = "Υποσύνολο" %}
|
||||||
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %}
|
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Έκπτωση" %}
|
||||||
{% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %}
|
{% set L_NEW_SUBTOTAL = "Νέο Υποσύνολο" %}
|
||||||
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α." %}
|
{% set L_VAT = "ΣΥΝΟΛΟ Φ.Π.Α. " ~ (quotation.global_vat_percent | int) ~ "%" %}
|
||||||
{% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %}
|
{% set L_SHIPPING_COST = "Μεταφορικά / Shipping" %}
|
||||||
{% set L_INSTALL_COST = "Εγκατάσταση / Installation" %}
|
{% set L_INSTALL_COST = "Εγκατάσταση / Installation" %}
|
||||||
{% set L_EXTRAS = quotation.extras_label or "Άλλα" %}
|
{% set L_EXTRAS = quotation.extras_label or "Άλλα" %}
|
||||||
@@ -403,12 +402,11 @@
|
|||||||
{% set L_DISC = "Disc." %}
|
{% set L_DISC = "Disc." %}
|
||||||
{% set L_QTY = "Qty" %}
|
{% set L_QTY = "Qty" %}
|
||||||
{% set L_UNIT = "Unit" %}
|
{% set L_UNIT = "Unit" %}
|
||||||
{% set L_VAT_COL = "VAT" %}
|
|
||||||
{% set L_TOTAL = "Total" %}
|
{% set L_TOTAL = "Total" %}
|
||||||
{% set L_SUBTOTAL = "Subtotal" %}
|
{% set L_SUBTOTAL = "Subtotal" %}
|
||||||
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %}
|
{% set L_GLOBAL_DISC = quotation.global_discount_label or "Discount" %}
|
||||||
{% set L_NEW_SUBTOTAL = "New Subtotal" %}
|
{% set L_NEW_SUBTOTAL = "New Subtotal" %}
|
||||||
{% set L_VAT = "Total VAT" %}
|
{% set L_VAT = "Total VAT " ~ (quotation.global_vat_percent | int) ~ "%" %}
|
||||||
{% set L_SHIPPING_COST = "Shipping / Transport" %}
|
{% set L_SHIPPING_COST = "Shipping / Transport" %}
|
||||||
{% set L_INSTALL_COST = "Installation" %}
|
{% set L_INSTALL_COST = "Installation" %}
|
||||||
{% set L_EXTRAS = quotation.extras_label or "Extras" %}
|
{% set L_EXTRAS = quotation.extras_label or "Extras" %}
|
||||||
@@ -469,7 +467,7 @@
|
|||||||
|
|
||||||
<div class="order-block">
|
<div class="order-block">
|
||||||
<div class="block-title">{{ L_ORDER_META }}</div>
|
<div class="block-title">{{ L_ORDER_META }}</div>
|
||||||
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ quotation.estimated_shipping_date }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted">—</td></tr>{% endif %}</tbody></table>
|
<table class="fields"><tbody>{% if quotation.order_type %}<tr><td class="lbl">{{ L_ORDER_TYPE }}</td><td class="val">{{ quotation.order_type }}</td></tr>{% endif %}{% if quotation.shipping_method %}<tr><td class="lbl">{{ L_SHIP_METHOD }}</td><td class="val">{{ quotation.shipping_method }}</td></tr>{% endif %}{% if quotation.estimated_shipping_date %}{% set _dp = quotation.estimated_shipping_date.split('-') %}{% set _dfmt = _dp[2] + '/' + _dp[1] + '/' + _dp[0] if _dp | length == 3 else quotation.estimated_shipping_date %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val">{{ _dfmt }}</td></tr>{% else %}<tr><td class="lbl">{{ L_SHIP_DATE }}</td><td class="val text-muted">—</td></tr>{% endif %}</tbody></table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -478,13 +476,12 @@
|
|||||||
<table class="items-table">
|
<table class="items-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:38%">{{ L_DESC }}</th>
|
<th style="width:44%">{{ L_DESC }}</th>
|
||||||
<th class="right" style="width:11%">{{ L_UNIT_COST }}</th>
|
<th class="right" style="width:13%">{{ L_UNIT_COST }}</th>
|
||||||
<th class="center" style="width:7%">{{ L_DISC }}</th>
|
<th class="center" style="width:8%">{{ L_DISC }}</th>
|
||||||
<th class="center" style="width:7%">{{ L_QTY }}</th>
|
<th class="center" style="width:8%">{{ L_QTY }}</th>
|
||||||
<th class="center" style="width:7%">{{ L_UNIT }}</th>
|
<th class="center" style="width:8%">{{ L_UNIT }}</th>
|
||||||
<th class="center" style="width:6%">{{ L_VAT_COL }}</th>
|
<th class="right" style="width:14%">{{ L_TOTAL }}</th>
|
||||||
<th class="right" style="width:12%">{{ L_TOTAL }}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -501,26 +498,19 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="center">{{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}</td>
|
<td class="center">{{ item.quantity | int if item.quantity == (item.quantity | int) else item.quantity }}</td>
|
||||||
<td class="center muted">{{ item.unit_type }}</td>
|
<td class="center muted">{{ item.unit_type }}</td>
|
||||||
<td class="center">
|
|
||||||
{% if item.vat_percent and item.vat_percent > 0 %}
|
|
||||||
{{ item.vat_percent | int }}%
|
|
||||||
{% else %}
|
|
||||||
<span class="dash">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="right">{{ item.line_total | format_money }}</td>
|
<td class="right">{{ item.line_total | format_money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if quotation.items | length == 0 %}
|
{% if quotation.items | length == 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-muted" style="text-align:center; padding: 12px;">—</td>
|
<td colspan="6" class="text-muted" style="text-align:center; padding: 12px;">—</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# ── Shipping / Install as special rows ── #}
|
{# ── Shipping / Install as special rows ── #}
|
||||||
{% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %}
|
{% set has_special = (quotation.shipping_cost and quotation.shipping_cost > 0) or (quotation.install_cost and quotation.install_cost > 0) %}
|
||||||
{% if has_special %}
|
{% if has_special %}
|
||||||
<tr class="special-spacer"><td colspan="7"></td></tr>
|
<tr class="special-spacer"><td colspan="6"></td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if quotation.shipping_cost and quotation.shipping_cost > 0 %}
|
{% if quotation.shipping_cost and quotation.shipping_cost > 0 %}
|
||||||
@@ -531,7 +521,6 @@
|
|||||||
<td class="center"><span class="dash">—</span></td>
|
<td class="center"><span class="dash">—</span></td>
|
||||||
<td class="center">1</td>
|
<td class="center">1</td>
|
||||||
<td class="center muted">—</td>
|
<td class="center muted">—</td>
|
||||||
<td class="center"><span class="dash">—</span></td>
|
|
||||||
<td class="right">{{ ship_net | format_money }}</td>
|
<td class="right">{{ ship_net | format_money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -544,7 +533,6 @@
|
|||||||
<td class="center"><span class="dash">—</span></td>
|
<td class="center"><span class="dash">—</span></td>
|
||||||
<td class="center">1</td>
|
<td class="center">1</td>
|
||||||
<td class="center muted">—</td>
|
<td class="center muted">—</td>
|
||||||
<td class="center"><span class="dash">—</span></td>
|
|
||||||
<td class="right">{{ install_net | format_money }}</td>
|
<td class="right">{{ install_net | format_money }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
0
backend/tickets/__init__.py
Normal file
92
backend/tickets/models.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import Optional, List
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
VALID_STATUSES = {"open", "waiting_on_customer", "waiting_on_staff", "resolved", "closed"}
|
||||||
|
VALID_PRIORITIES = {"low", "medium", "high", "urgent"}
|
||||||
|
VALID_OPENED_VIA = {"app", "email", "phone", "staff"}
|
||||||
|
VALID_SENDERS = {"staff", "customer"}
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCreate(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
customer_name: Optional[str] = None
|
||||||
|
subject: str = Field(..., max_length=500)
|
||||||
|
device_id: Optional[str] = None
|
||||||
|
device_serial: Optional[str] = None
|
||||||
|
opened_via: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("priority")
|
||||||
|
@classmethod
|
||||||
|
def check_priority(cls, v):
|
||||||
|
if v is not None and v not in VALID_PRIORITIES:
|
||||||
|
raise ValueError(f"priority must be one of {VALID_PRIORITIES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdate(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
device_id: Optional[str] = None
|
||||||
|
device_serial: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def check_status(cls, v):
|
||||||
|
if v is not None and v not in VALID_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {VALID_STATUSES}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreate(BaseModel):
|
||||||
|
sender_type: str
|
||||||
|
sender_id: str
|
||||||
|
sender_name: Optional[str] = None
|
||||||
|
body: str
|
||||||
|
is_internal: bool = False
|
||||||
|
|
||||||
|
@field_validator("sender_type")
|
||||||
|
@classmethod
|
||||||
|
def check_sender_type(cls, v):
|
||||||
|
if v not in VALID_SENDERS:
|
||||||
|
raise ValueError(f"sender_type must be one of {VALID_SENDERS}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class EscalateIn(BaseModel):
|
||||||
|
entry_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
sender_type: str
|
||||||
|
sender_id: str
|
||||||
|
sender_name: Optional[str]
|
||||||
|
body: str
|
||||||
|
is_internal: bool
|
||||||
|
created_at: datetime
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TicketOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
customer_id: str
|
||||||
|
customer_name: Optional[str]
|
||||||
|
device_id: Optional[str]
|
||||||
|
device_serial: Optional[str]
|
||||||
|
subject: str
|
||||||
|
status: str
|
||||||
|
priority: Optional[str]
|
||||||
|
opened_via: Optional[str]
|
||||||
|
linked_entry_id: Optional[UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
messages: List[MessageOut] = []
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class TicketListResponse(BaseModel):
|
||||||
|
data: List[TicketOut]
|
||||||
|
pagination: dict
|
||||||
46
backend/tickets/orm.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, String, Text, DateTime, Boolean, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from database.postgres import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicket(Base):
|
||||||
|
__tablename__ = "support_tickets"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
customer_id = Column(String(128), nullable=False) # Firestore ID (moves to UUID when customers migrate)
|
||||||
|
customer_name = Column(String(255), nullable=True) # denormalized snapshot
|
||||||
|
device_id = Column(String(128), nullable=True) # Firestore ID
|
||||||
|
device_serial = Column(String(64), nullable=True) # denormalized snapshot
|
||||||
|
subject = Column(String(500), nullable=False)
|
||||||
|
status = Column(String(30), nullable=False, default="open")
|
||||||
|
# open | waiting_on_customer | waiting_on_staff | resolved | closed
|
||||||
|
priority = Column(String(10), nullable=True) # low | medium | high | urgent
|
||||||
|
opened_via = Column(String(20), nullable=True) # app | email | phone | staff
|
||||||
|
linked_entry_id = Column(UUID(as_uuid=True), ForeignKey("crm_entries.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
messages = relationship("TicketMessage", back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan", order_by="TicketMessage.created_at", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessage(Base):
|
||||||
|
__tablename__ = "ticket_messages"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticket_id = Column(UUID(as_uuid=True), ForeignKey("support_tickets.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
sender_type = Column(String(10), nullable=False) # 'staff' | 'customer'
|
||||||
|
sender_id = Column(String(128), nullable=False)
|
||||||
|
sender_name = Column(String(255), nullable=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
is_internal = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
|
||||||
|
|
||||||
|
ticket = relationship("SupportTicket", back_populates="messages")
|
||||||
87
backend/tickets/router.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from uuid import UUID
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from database.postgres import get_pg_session
|
||||||
|
from auth.dependencies import require_permission
|
||||||
|
from auth.models import TokenPayload
|
||||||
|
from tickets import service
|
||||||
|
from tickets.models import TicketCreate, TicketUpdate, MessageCreate, EscalateIn, TicketOut, TicketListResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tickets", tags=["tickets"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TicketListResponse)
|
||||||
|
async def list_tickets(
|
||||||
|
status: str | None = Query(None),
|
||||||
|
priority: str | None = Query(None),
|
||||||
|
customer_id: str | None = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
rows, total = await service.list_tickets(db, status, priority, customer_id, page, limit)
|
||||||
|
return {"data": rows, "pagination": {"page": page, "limit": limit, "total": total}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-customer/{customer_id}", response_model=list[TicketOut])
|
||||||
|
async def list_by_customer(
|
||||||
|
customer_id: str,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await service.list_by_customer(db, customer_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-device/{device_id}", response_model=list[TicketOut])
|
||||||
|
async def list_by_device(
|
||||||
|
device_id: str,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await service.list_by_device(db, device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{ticket_id}", response_model=TicketOut)
|
||||||
|
async def get_ticket(
|
||||||
|
ticket_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "view")),
|
||||||
|
):
|
||||||
|
return await service.get_ticket(db, ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TicketOut, status_code=201)
|
||||||
|
async def create_ticket(
|
||||||
|
body: TicketCreate,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "add")),
|
||||||
|
):
|
||||||
|
return await service.create_ticket(db, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{ticket_id}", response_model=TicketOut)
|
||||||
|
async def update_ticket(
|
||||||
|
ticket_id: UUID, body: TicketUpdate,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.update_ticket(db, ticket_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/messages", response_model=TicketOut)
|
||||||
|
async def add_message(
|
||||||
|
ticket_id: UUID, body: MessageCreate,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.add_message(db, ticket_id, body)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{ticket_id}/escalate", response_model=TicketOut)
|
||||||
|
async def escalate(
|
||||||
|
ticket_id: UUID, body: EscalateIn,
|
||||||
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
|
_user: TokenPayload = Depends(require_permission("crm", "edit")),
|
||||||
|
):
|
||||||
|
return await service.escalate_to_issue(db, ticket_id, body.entry_id)
|
||||||
91
backend/tickets/service.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import uuid
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from tickets.orm import SupportTicket, TicketMessage
|
||||||
|
from tickets.models import TicketCreate, TicketUpdate, MessageCreate
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ticket(db: AsyncSession, data: TicketCreate) -> SupportTicket:
|
||||||
|
ticket = SupportTicket(**data.model_dump())
|
||||||
|
db.add(ticket)
|
||||||
|
await db.commit()
|
||||||
|
return await _get_ticket(db, ticket.id, include_internal=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
|
||||||
|
return await _get_ticket(db, ticket_id, include_internal)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_tickets(
|
||||||
|
db: AsyncSession,
|
||||||
|
status: str | None, priority: str | None, customer_id: str | None,
|
||||||
|
page: int, limit: int,
|
||||||
|
) -> tuple[list[SupportTicket], int]:
|
||||||
|
limit = min(100, max(1, limit))
|
||||||
|
offset = (max(1, page) - 1) * limit
|
||||||
|
|
||||||
|
q = select(SupportTicket).options(selectinload(SupportTicket.messages))
|
||||||
|
if status: q = q.where(SupportTicket.status == status)
|
||||||
|
if priority: q = q.where(SupportTicket.priority == priority)
|
||||||
|
if customer_id: q = q.where(SupportTicket.customer_id == customer_id)
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar()
|
||||||
|
rows = (await db.execute(q.order_by(SupportTicket.created_at.desc()).limit(limit).offset(offset))).scalars().all()
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ticket(db: AsyncSession, ticket_id: uuid.UUID, data: TicketUpdate) -> SupportTicket:
|
||||||
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(ticket, field, value)
|
||||||
|
await db.commit()
|
||||||
|
return await _get_ticket(db, ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def add_message(db: AsyncSession, ticket_id: uuid.UUID, data: MessageCreate) -> SupportTicket:
|
||||||
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
|
msg = TicketMessage(ticket_id=ticket_id, **data.model_dump())
|
||||||
|
db.add(msg)
|
||||||
|
|
||||||
|
# Auto-advance ticket status based on who replied (skip if already resolved/closed)
|
||||||
|
if ticket.status not in ("resolved", "closed"):
|
||||||
|
if data.sender_type == "staff" and not data.is_internal:
|
||||||
|
ticket.status = "waiting_on_customer"
|
||||||
|
elif data.sender_type == "customer":
|
||||||
|
ticket.status = "waiting_on_staff"
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return await _get_ticket(db, ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def escalate_to_issue(db: AsyncSession, ticket_id: uuid.UUID, entry_id: uuid.UUID) -> SupportTicket:
|
||||||
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
|
ticket.linked_entry_id = entry_id
|
||||||
|
await db.commit()
|
||||||
|
return await _get_ticket(db, ticket_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_customer(db: AsyncSession, customer_id: str) -> list[SupportTicket]:
|
||||||
|
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
|
||||||
|
SupportTicket.customer_id == customer_id
|
||||||
|
).order_by(SupportTicket.created_at.desc())
|
||||||
|
return (await db.execute(q)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_device(db: AsyncSession, device_id: str) -> list[SupportTicket]:
|
||||||
|
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(
|
||||||
|
SupportTicket.device_id == device_id
|
||||||
|
).order_by(SupportTicket.created_at.desc())
|
||||||
|
return (await db.execute(q)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_ticket(db: AsyncSession, ticket_id: uuid.UUID, include_internal: bool = True) -> SupportTicket:
|
||||||
|
q = select(SupportTicket).options(selectinload(SupportTicket.messages)).where(SupportTicket.id == ticket_id)
|
||||||
|
result = (await db.execute(q)).scalar_one_or_none()
|
||||||
|
if not result:
|
||||||
|
raise NotFoundError("Ticket")
|
||||||
|
if not include_internal:
|
||||||
|
result.messages = [m for m in result.messages if not m.is_internal]
|
||||||
|
return result
|
||||||
@@ -41,3 +41,11 @@ class UserInDB(UserCreate):
|
|||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
users: List[UserInDB]
|
users: List[UserInDB]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class SetPasswordRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = "Bell1234!" # default reset value
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from auth.models import TokenPayload
|
|||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from users.models import (
|
from users.models import (
|
||||||
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
||||||
|
SetPasswordRequest, ResetPasswordRequest,
|
||||||
)
|
)
|
||||||
from users import service
|
from users import service
|
||||||
|
|
||||||
@@ -95,6 +96,26 @@ async def unassign_device(
|
|||||||
return service.unassign_device(user_id, device_id)
|
return service.unassign_device(user_id, device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/set-password", status_code=204)
|
||||||
|
async def set_password(
|
||||||
|
user_id: str,
|
||||||
|
body: SetPasswordRequest,
|
||||||
|
_user: TokenPayload = Depends(require_permission("app_users", "full_edit")),
|
||||||
|
):
|
||||||
|
"""Set a new password for the user via Firebase Auth (requires uid on the user doc)."""
|
||||||
|
service.set_password(user_id, body.password)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reset-password", status_code=204)
|
||||||
|
async def reset_password(
|
||||||
|
user_id: str,
|
||||||
|
body: ResetPasswordRequest,
|
||||||
|
_user: TokenPayload = Depends(require_permission("app_users", "full_edit")),
|
||||||
|
):
|
||||||
|
"""Reset a user's password to the supplied value (default: Bell1234!)."""
|
||||||
|
service.set_password(user_id, body.new_password)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{user_id}/photo")
|
@router.post("/{user_id}/photo")
|
||||||
async def upload_photo(
|
async def upload_photo(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from google.cloud.firestore_v1 import DocumentReference
|
from google.cloud.firestore_v1 import DocumentReference
|
||||||
|
|
||||||
|
from firebase_admin import auth as firebase_auth
|
||||||
from shared.firebase import get_db, get_bucket
|
from shared.firebase import get_db, get_bucket
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError, ValidationError
|
||||||
from users.models import UserCreate, UserUpdate, UserInDB
|
from users.models import UserCreate, UserUpdate, UserInDB
|
||||||
|
|
||||||
COLLECTION = "users"
|
COLLECTION = "users"
|
||||||
@@ -252,6 +253,31 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(user_doc_id: str, new_password: str) -> None:
|
||||||
|
"""Set a Firebase Auth password for a user via their Firestore document ID.
|
||||||
|
|
||||||
|
Requires the user document to have a non-empty `uid` field — populated
|
||||||
|
automatically for users who registered via the Flutter app.
|
||||||
|
"""
|
||||||
|
if not new_password or len(new_password) < 6:
|
||||||
|
raise ValidationError("Password must be at least 6 characters.")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("User")
|
||||||
|
|
||||||
|
uid = doc.to_dict().get("uid", "")
|
||||||
|
if not uid:
|
||||||
|
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
firebase_auth.update_user(uid, password=new_password)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Firebase Auth error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
def upload_photo(user_doc_id: str, file_bytes: bytes, filename: str, content_type: str) -> str:
|
||||||
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
|
"""Upload a profile photo to Firebase Storage and update the user's photo_url."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|||||||
@@ -1,38 +1,67 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: bellsystems-backend
|
container_name: bellsystems-backend-v2
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
# Persistent data - lives outside the container
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./data/built_melodies:/app/storage/built_melodies
|
- ./data/built_melodies:/app/storage/built_melodies
|
||||||
- ./data/firmware:/app/storage/firmware
|
- ./data/firmware:/app/storage/firmware
|
||||||
- ./data/flash_assets:/app/storage/flash_assets
|
- ./data/flash_assets:/app/storage/flash_assets
|
||||||
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
# Auto-deploy: project root so container can write the trigger file
|
|
||||||
- /home/bellsystems/bellsystems-cp:/home/bellsystems/bellsystems-cp
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8002:8000" # different port — v1 backend runs on 8000
|
||||||
depends_on: []
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: bellsystems-frontend
|
container_name: bellsystems-frontend-v2
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5174:5174" # different port — v1 frontend runs on 5173
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: bellsystems-nginx
|
container_name: bellsystems-nginx-v2
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-80}:80"
|
- "8001:80" # access v2 on localhost:8001
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
0
frontend/src/_archive/App.css
Normal file
25
frontend/src/_archive/assets/comms/call.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#000000;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M500.177,55.798c0,0-21.735-7.434-39.551-11.967C411.686,31.369,308.824,24.727,256,24.727
|
||||||
|
S100.314,31.369,51.374,43.831c-17.816,4.534-39.551,11.967-39.551,11.967c-7.542,2.28-12.444,9.524-11.76,17.374l8.507,97.835
|
||||||
|
c0.757,8.596,7.957,15.201,16.581,15.201h84.787c8.506,0,15.643-6.416,16.553-14.878l4.28-39.973
|
||||||
|
c0.847-7.93,7.2-14.138,15.148-14.815c0,0,68.484-6.182,110.081-6.182c41.586,0,110.08,6.182,110.08,6.182
|
||||||
|
c7.949,0.676,14.302,6.885,15.148,14.815l4.29,39.973c0.9,8.462,8.038,14.878,16.545,14.878h84.777
|
||||||
|
c8.632,0,15.832-6.605,16.589-15.201l8.507-97.835C512.621,65.322,507.72,58.078,500.177,55.798z"/>
|
||||||
|
<path class="st0" d="M357.503,136.629h-55.365v46.137h-92.275v-46.137h-55.365c0,0-9.228,119.957-119.957,207.618
|
||||||
|
c0,32.296,0,129.95,0,129.95c0,7.218,5.857,13.076,13.075,13.076h416.768c7.218,0,13.076-5.858,13.076-13.076
|
||||||
|
c0,0,0-97.654,0-129.95C366.73,256.586,357.503,136.629,357.503,136.629z M338.768,391.42v37.406h-37.396V391.42H338.768z
|
||||||
|
M338.768,332.27v37.406h-37.396V332.27H338.768z M301.372,310.518v-37.396h37.396v37.396H301.372z M274.698,391.42v37.406h-37.396
|
||||||
|
V391.42H274.698z M274.698,332.27v37.406h-37.396V332.27H274.698z M274.698,273.122v37.396h-37.396v-37.396H274.698z
|
||||||
|
M210.629,391.42v37.406h-37.397V391.42H210.629z M210.629,332.27v37.406h-37.397V332.27H210.629z M210.629,273.122v37.396h-37.397
|
||||||
|
v-37.396H210.629z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
8
frontend/src/_archive/assets/comms/email.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 6.3500002 6.3500002" id="svg1976" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<defs id="defs1970"/>
|
||||||
|
|
||||||
|
<g id="layer1" style="display:inline">
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
4
frontend/src/_archive/assets/comms/inbound.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.16421 9.66421L15.4142 3.41421L12.5858 0.585785L6.33579 6.83578L3.5 4L2 5.5V14H10.5L12 12.5L9.16421 9.66421Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 365 B |
17
frontend/src/_archive/assets/comms/inperson.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#000000;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M458.159,404.216c-18.93-33.65-49.934-71.764-100.409-93.431c-28.868,20.196-63.938,32.087-101.745,32.087
|
||||||
|
c-37.828,0-72.898-11.89-101.767-32.087c-50.474,21.667-81.479,59.782-100.398,93.431C28.731,448.848,48.417,512,91.842,512
|
||||||
|
c43.426,0,164.164,0,164.164,0s120.726,0,164.153,0C463.583,512,483.269,448.848,458.159,404.216z"/>
|
||||||
|
<path class="st0" d="M256.005,300.641c74.144,0,134.231-60.108,134.231-134.242v-32.158C390.236,60.108,330.149,0,256.005,0
|
||||||
|
c-74.155,0-134.252,60.108-134.252,134.242V166.4C121.753,240.533,181.851,300.641,256.005,300.641z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
8
frontend/src/_archive/assets/comms/internal.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 18V9C6 7.34315 7.34315 6 9 6H39C40.6569 6 42 7.34315 42 9V18" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M32 24V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M24 15V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 19V31" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 30V39C6 40.6569 7.34315 42 9 42H39C40.6569 42 42 40.6569 42 39V30" stroke="#000000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 855 B |
2
frontend/src/_archive/assets/comms/mail.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="phone-out" class="icon glyph"><path d="M21,15v3.93a2,2,0,0,1-2.29,2A18,18,0,0,1,3.14,5.29,2,2,0,0,1,5.13,3H9a1,1,0,0,1,1,.89,10.74,10.74,0,0,0,1,3.78,1,1,0,0,1-.42,1.26l-.86.49a1,1,0,0,0-.33,1.46,14.08,14.08,0,0,0,3.69,3.69,1,1,0,0,0,1.46-.33l.49-.86A1,1,0,0,1,16.33,13a10.74,10.74,0,0,0,3.78,1A1,1,0,0,1,21,15Z" style="fill:#231f20"></path><path d="M21,10a1,1,0,0,1-1-1,5,5,0,0,0-5-5,1,1,0,0,1,0-2,7,7,0,0,1,7,7A1,1,0,0,1,21,10Z" style="fill:#231f20"></path><path d="M17,10a1,1,0,0,1-1-1,1,1,0,0,0-1-1,1,1,0,0,1,0-2,3,3,0,0,1,3,3A1,1,0,0,1,17,10Z" style="fill:#231f20"></path></svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
2
frontend/src/_archive/assets/comms/note.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" id="create-note" class="icon glyph"><path d="M20.71,3.29a2.91,2.91,0,0,0-2.2-.84,3.25,3.25,0,0,0-2.17,1L9.46,10.29s0,0,0,0a.62.62,0,0,0-.11.17,1,1,0,0,0-.1.18l0,0L8,14.72A1,1,0,0,0,9,16a.9.9,0,0,0,.28,0l4-1.17,0,0,.18-.1a.62.62,0,0,0,.17-.11l0,0,6.87-6.88a3.25,3.25,0,0,0,1-2.17A2.91,2.91,0,0,0,20.71,3.29Z"></path><path d="M20,22H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a1,1,0,0,1,0,2H4V20H20V12a1,1,0,0,1,2,0v8A2,2,0,0,1,20,22Z" style="fill:#231f20"></path></svg>
|
||||||
|
After Width: | Height: | Size: 666 B |
4
frontend/src/_archive/assets/comms/outbound.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H5.50003L4.00003 3.5L6.83581 6.33579L0.585815 12.5858L3.41424 15.4142L9.66424 9.16421L12.5 12L14 10.5L14 2Z" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 367 B |
2
frontend/src/_archive/assets/comms/sms.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7 1.3 3 4.1 4.8 7.3 4.8 66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32zM128.2 304H116c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H156c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-19 38.6-42.4 38.6zm191.8-8c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8v-68.2l-24.8 55.8c-2.9 5.9-11.4 5.9-14.3 0L224 227.8V296c0 4.4-3.6 8-8 8h-16c-4.4 0-8-3.6-8-8V192c0-8.8 7.2-16 16-16h16c6.1 0 11.6 3.4 14.3 8.8l17.7 35.4 17.7-35.4c2.7-5.4 8.3-8.8 14.3-8.8h16c8.8 0 16 7.2 16 16v104zm48.3 8H356c-4.4 0-8-3.6-8-8v-16c0-4.4 3.6-8 8-8h12.3c6 0 10.4-3.5 10.4-6.6 0-1.3-.8-2.7-2.1-3.8l-21.9-18.8c-8.5-7.2-13.3-17.5-13.3-28.1 0-21.3 19-38.6 42.4-38.6H396c4.4 0 8 3.6 8 8v16c0 4.4-3.6 8-8 8h-12.3c-6 0-10.4 3.5-10.4 6.6 0 1.3.8 2.7 2.1 3.8l21.9 18.8c8.5 7.2 13.3 17.5 13.3 28.1.1 21.3-18.9 38.6-42.3 38.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
12
frontend/src/_archive/assets/comms/whatsapp.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 32 32" xml:space="preserve">
|
||||||
|
<path d="M17,0C8.7,0,2,6.7,2,15c0,3.4,1.1,6.6,3.2,9.2l-2.1,6.4c-0.1,0.4,0,0.8,0.3,1.1C3.5,31.9,3.8,32,4,32c0.1,0,0.3,0,0.4-0.1
|
||||||
|
l6.9-3.1C13.1,29.6,15,30,17,30c8.3,0,15-6.7,15-15S25.3,0,17,0z M25.7,20.5c-0.4,1.2-1.9,2.2-3.2,2.4C22.2,23,21.9,23,21.5,23
|
||||||
|
c-0.8,0-2-0.2-4.1-1.1c-2.4-1-4.8-3.1-6.7-5.8L10.7,16C10.1,15.1,9,13.4,9,11.6c0-2.2,1.1-3.3,1.5-3.8c0.5-0.5,1.2-0.8,2-0.8
|
||||||
|
c0.2,0,0.3,0,0.5,0c0.7,0,1.2,0.2,1.7,1.2l0.4,0.8c0.3,0.8,0.7,1.7,0.8,1.8c0.3,0.6,0.3,1.1,0,1.6c-0.1,0.3-0.3,0.5-0.5,0.7
|
||||||
|
c-0.1,0.2-0.2,0.3-0.3,0.3c-0.1,0.1-0.1,0.1-0.2,0.2c0.3,0.5,0.9,1.4,1.7,2.1c1.2,1.1,2.1,1.4,2.6,1.6l0,0c0.2-0.2,0.4-0.6,0.7-0.9
|
||||||
|
l0.1-0.2c0.5-0.7,1.3-0.9,2.1-0.6c0.4,0.2,2.6,1.2,2.6,1.2l0.2,0.1c0.3,0.2,0.7,0.3,0.9,0.7C26.2,18.5,25.9,19.8,25.7,20.5z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.73 44.73">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m4.09,8.19v32.45h32.45v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.19h4.09ZM40.64,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.46c0,1.1-.41,2.05-1.22,2.83-.81.78-1.77,1.17-2.87,1.17H12.18c-1.1,0-2.05-.39-2.83-1.17-.78-.78-1.17-1.72-1.17-2.83V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.83-1.22h28.46Zm0,32.55V4.09H12.18v28.46h28.46Zm-8.09-8.19c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-8.09v-4.09h8.09v-4h-4.09v-4.09h4.09v-4.09h-8.09v-4h8.09c1.1,0,2.06.38,2.87,1.15.81.76,1.22,1.71,1.22,2.85v3.02c0,.88-.29,1.61-.88,2.19-.58.58-1.3.88-2.14.88.84,0,1.56.29,2.14.88.58.58.88,1.3.88,2.14v3.07Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 845 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.73 44.73">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m4.09,8.19v32.45h32.45v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.19h4.09ZM40.64,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.46c0,1.1-.41,2.05-1.22,2.83-.81.78-1.77,1.17-2.87,1.17H12.18c-1.1,0-2.05-.39-2.83-1.17-.78-.78-1.17-1.72-1.17-2.83V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.83-1.22h28.46Zm0,32.55V4.09H12.18v28.46h28.46Zm-16.27-4.09c-1.1,0-2.05-.4-2.83-1.19-.78-.8-1.17-1.76-1.17-2.9v-12.18c0-1.14.39-2.09,1.17-2.85.78-.76,1.72-1.15,2.83-1.15h8.19v4h-8.19v4.09h4.09c1.1,0,2.06.4,2.87,1.19.81.8,1.22,1.76,1.22,2.9v4c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-4.09Zm0-8.09v4h4.09v-4h-4.09Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 853 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.68 44.68">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m4.09,8.18v32.41h32.41v4.09H4.09c-1.1,0-2.06-.41-2.87-1.22-.81-.81-1.22-1.77-1.22-2.87V8.18h4.09ZM40.59,0c1.1,0,2.06.41,2.87,1.22.81.81,1.22,1.77,1.22,2.87v28.42c0,1.1-.41,2.04-1.22,2.82-.81.78-1.77,1.17-2.87,1.17H12.17c-1.1,0-2.04-.39-2.82-1.17s-1.17-1.72-1.17-2.82V4.09c0-1.1.39-2.06,1.17-2.87.78-.81,1.72-1.22,2.82-1.22h28.42Zm0,32.51V4.09H12.17v28.42h28.42Zm-12.17-24.33c1.1,0,2.06.38,2.87,1.14.81.76,1.22,1.71,1.22,2.85v12.17c0,1.14-.41,2.1-1.22,2.9-.81.8-1.77,1.19-2.87,1.19h-8.08v-4.09h8.08v-3.99h-4.09c-1.1,0-2.04-.4-2.82-1.19-.78-.79-1.17-1.76-1.17-2.9v-4.09c0-1.14.39-2.08,1.17-2.85.78-.76,1.72-1.14,2.82-1.14h4.09Zm0,8.08v-4.09h-4.09v4.09h4.09Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 865 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path d="m24.72,10.46v13.65c0,.25-.09.47-.27.66-.18.18-.42.27-.71.27h-9.72c-.29,0-.53-.09-.71-.27-.18-.18-.27-.4-.27-.66v-1.97c0-.29.09-.53.27-.71.18-.18.42-.27.71-.27h6.83v-10.71c0-.29.09-.53.27-.71s.4-.27.66-.27h1.97c.29,0,.53.09.71.27.18.18.27.42.27.71Z"/>
|
||||||
|
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 979 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62s-9.45,6.41-15.62,6.44c-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Z"/>
|
||||||
|
<path d="m23.69,30.21c1.22-.03,2.25-.5,3.1-1.38.85-.89,1.28-1.96,1.28-3.21,0-1.01-.3-1.93-.91-2.77-.61-.83-1.38-1.39-2.32-1.67l-4.59-1.36c-.28-.07-.5-.23-.65-.47-.16-.24-.23-.54-.23-.89s.11-.65.34-.91c.23-.26.51-.39.86-.39h2.87c.42,0,.85.14,1.3.42.14.1.3.15.5.13.19-.02.37-.1.55-.23l1.2-1.15c.17-.14.25-.34.23-.6-.02-.26-.11-.46-.29-.6-.9-.7-1.98-1.08-3.23-1.15v-2.5c0-.24-.08-.44-.23-.6s-.34-.23-.55-.23h-1.67c-.21,0-.39.08-.55.23s-.23.36-.23.6v2.45c-1.22.04-2.25.5-3.1,1.38-.85.89-1.28,1.96-1.28,3.21,0,1.01.3,1.93.91,2.77.61.83,1.38,1.39,2.32,1.67l4.59,1.36c.24.07.45.23.63.5.17.26.26.53.26.81,0,.38-.11.7-.34.97-.23.26-.51.39-.86.39h-2.87c-.38,0-.82-.12-1.3-.37-.17-.1-.36-.16-.55-.16s-.36.07-.5.21l-1.2,1.15c-.17.17-.25.39-.23.65.02.26.13.46.34.6.94.7,2,1.08,3.18,1.15v2.45c0,.24.08.44.23.6.16.16.34.23.55.23h1.67c.21,0,.39-.08.55-.23.16-.16.23-.36.23-.6v-2.45Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48.38 43">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path d="m32.25,10.23c0-.54-.2-1.02-.6-1.42L23.43.6c-.4-.4-.87-.6-1.42-.6h-.52v10.75h10.75v-.52Z"/>
|
||||||
|
<path d="m19.4,29.15c-.26-.26-.39-.57-.39-.95v-2.67c0-.37.13-.69.39-.97.26-.27.57-.41.95-.41h11.9v-10.75h-11.44c-.54,0-1.01-.19-1.4-.58-.39-.39-.58-.85-.58-1.4V0H2.02c-.54,0-1.02.19-1.42.58-.4.39-.6.87-.6,1.44v38.96c0,.54.2,1.02.6,1.42s.87.6,1.42.6h28.21c.57,0,1.05-.2,1.44-.6.39-.4.58-.87.58-1.42v-11.44h-11.9c-.37,0-.69-.13-.95-.39Zm-5.34-2.56c-.7.73-1.56,1.11-2.56,1.14v2.02c0,.2-.06.37-.19.49-.13.13-.28.19-.45.19h-1.38c-.17,0-.32-.06-.45-.19-.13-.13-.19-.29-.19-.49v-2.02c-.97-.06-1.85-.37-2.62-.95-.17-.11-.27-.28-.28-.49-.01-.22.05-.39.19-.54l.99-.95c.11-.11.25-.17.41-.17s.31.04.45.13c.4.2.76.3,1.07.3h2.37c.29,0,.52-.11.71-.32.19-.22.28-.48.28-.8,0-.23-.07-.45-.21-.67-.14-.22-.32-.35-.52-.41l-3.78-1.12c-.77-.23-1.41-.69-1.91-1.38-.5-.69-.75-1.45-.75-2.28,0-1.03.35-1.91,1.05-2.64.7-.73,1.55-1.11,2.56-1.14v-2.02c0-.2.06-.37.19-.49.13-.13.28-.19.45-.19h1.38c.17,0,.32.06.45.19.13.13.19.29.19.49v2.06c1.03.06,1.92.37,2.67.95.14.11.22.28.24.49.01.22-.05.38-.19.49l-.99.95c-.14.11-.29.18-.45.19-.16.01-.29-.02-.41-.11-.37-.23-.73-.34-1.07-.34h-2.37c-.29,0-.52.11-.71.32-.19.21-.28.47-.28.75s.06.53.19.73c.13.2.31.33.54.39l3.78,1.12c.77.23,1.41.69,1.91,1.38.5.69.75,1.45.75,2.28,0,1.03-.35,1.91-1.05,2.64Z"/>
|
||||||
|
<path d="m47.95,25.89l-8.04-8.13c-.26-.26-.57-.39-.95-.39s-.69.13-.95.39c-.26.26-.39.57-.39.95v5.46h-5.37v5.37h5.37v5.5c0,.4.14.72.41.97.27.24.59.37.95.39.36.01.67-.12.92-.41l8.04-8.13c.29-.29.43-.62.43-1.01s-.14-.71-.43-.97Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
6
frontend/src/_archive/assets/customer-status/churned.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 43.63 43.63">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m2.52,0l41.1,41.15-2.52,2.48-11.34-11.34H11.91l-7.91,7.91V6.53L0,2.52,2.52,0Zm37.2.57c1.08,0,2,.38,2.76,1.14s1.14,1.68,1.14,2.76v23.81c0,1.08-.37,2.01-1.1,2.79-.73.78-1.64,1.18-2.71,1.21l-13.86-13.86h9.76v-4h-13.76l-1.95-1.95h15.72v-4h-15.91v3.81L8.1.57h31.63ZM11.91,18.43h4l-4-4v4Zm4,5.95v-4h-4v4h4Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
5
frontend/src/_archive/assets/customer-status/client.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 7C9.65685 7 11 5.65685 11 4C11 2.34315 9.65685 1 8 1C6.34315 1 5 2.34315 5 4C5 5.65685 6.34315 7 8 7Z" fill="currentColor"/>
|
||||||
|
<path d="M14 12C14 10.3431 12.6569 9 11 9H5C3.34315 9 2 10.3431 2 12V15H14V12Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Zm6.4,6.05l6.05-6.05-6.05-6.05,4.81-4.81,6.05,6.05,6.05-6.05,4.81,4.81-6.05,6.05,6.05,6.05-4.81,4.81-6.05-6.05-6.05,6.05-4.81-4.81Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 824 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 39.16">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m43.51,33.64c.44.81.59,1.64.43,2.51-.16.86-.57,1.58-1.23,2.15-.67.57-1.46.86-2.37.86H3.66c-.91,0-1.7-.29-2.37-.86-.67-.57-1.08-1.29-1.23-2.15-.16-.86-.01-1.7.43-2.51L18.81,1.86c.44-.81,1.09-1.36,1.94-1.64.85-.29,1.69-.29,2.52,0,.83.29,1.47.84,1.92,1.64l18.32,31.78Zm-21.49-6.58c-.97,0-1.79.35-2.49,1.04-.69.69-1.04,1.52-1.04,2.49s.35,1.79,1.04,2.49c.69.69,1.52,1.04,2.49,1.04s1.79-.34,2.47-1.02c.68-.68,1.02-1.51,1.02-2.49s-.34-1.81-1.02-2.51c-.68-.69-1.5-1.04-2.47-1.04Zm-3.37-12.64l.59,10.41c0,.23.09.44.27.61.18.17.39.25.63.25h3.72c.23,0,.44-.08.63-.25.18-.17.27-.37.27-.61l.59-10.41c.03-.26-.05-.48-.23-.67-.18-.18-.42-.27-.7-.27h-4.81c-.26,0-.48.09-.67.27-.18.18-.27.4-.27.67Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 888 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62s-9.45,6.41-15.62,6.44c-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm4.81,0c0,4.77,1.69,8.83,5.08,12.18,3.38,3.35,7.44,5.05,12.18,5.08,4.74.03,8.8-1.66,12.18-5.08,3.38-3.41,5.08-7.47,5.08-12.18s-1.69-8.77-5.08-12.18c-3.38-3.41-7.44-5.1-12.18-5.08-4.74.03-8.8,1.72-12.18,5.08-3.38,3.35-5.08,7.42-5.08,12.18Zm4.68,2.25l4.15-4.06,4.1,4.15,12.76-12.84,4.15,4.1-12.84,12.76-4.06,4.15-4.1-4.15-4.15-4.1Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41.48 41.48">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m31.16,20.74c0,.57-.21,1.05-.62,1.45-.42.4-.92.6-1.52.6H8.28L0,31.16V2.04C0,1.48.2,1,.6.6c.4-.4.88-.6,1.45-.6h26.97c.6,0,1.1.2,1.52.6.42.4.62.88.62,1.45v18.7Zm8.28-12.46c.56,0,1.05.2,1.45.6.4.4.6.88.6,1.45v31.16l-8.28-8.28H10.32c-.57,0-1.05-.2-1.45-.6s-.6-.88-.6-1.45v-4.19h26.97V8.28h4.19Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
4
frontend/src/_archive/assets/customer-status/order.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.58579 4.58579C5 5.17157 5 6.11438 5 8V17C5 18.8856 5 19.8284 5.58579 20.4142C6.17157 21 7.11438 21 9 21H15C16.8856 21 17.8284 21 18.4142 20.4142C19 19.8284 19 18.8856 19 17V8C19 6.11438 19 5.17157 18.4142 4.58579C17.8284 4 16.8856 4 15 4H9C7.11438 4 6.17157 4 5.58579 4.58579ZM9 8C8.44772 8 8 8.44772 8 9C8 9.55228 8.44772 10 9 10H15C15.5523 10 16 9.55228 16 9C16 8.44772 15.5523 8 15 8H9ZM9 12C8.44772 12 8 12.4477 8 13C8 13.5523 8.44772 14 9 14H15C15.5523 14 16 13.5523 16 13C16 12.4477 15.5523 12 15 12H9ZM9 16C8.44772 16 8 16.4477 8 17C8 17.5523 8.44772 18 9 18H13C13.5523 18 14 17.5523 14 17C14 16.4477 13.5523 16 13 16H9Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 930 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 53.85 41.66">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path d="m39.43,33.73l.04-.51.72.55c1.2.88,2.82.73,3.84-.36.91-.97.88-2.36-.08-3.54-4.03-4.91-8.06-9.82-12.09-14.72l-3.61-4.4q-.5-.6-1.18-.22l-1.56.87c-1.59.89-3.17,1.78-4.77,2.65-2.53,1.37-5.1,1.54-7.65.51-1.77-.72-2.79-2.07-2.94-3.89-.08-.95.1-1.87.54-2.84.13-.28.15-.5.07-.64-.08-.14-.3-.23-.6-.25-.12,0-.23,0-.35,0H1.75C.36,6.94,0,7.31,0,8.71,0,14.45,0,20.19,0,25.93c0,.21,0,.46.04.68.1.65.48.99,1.14,1.01.46.01.91.01,1.37,0,.36,0,.72,0,1.08,0,.2.02.32-.06.45-.22.31-.39.63-.77.94-1.16.22-.26.43-.52.64-.78,1.1-1.35,2.65-2.11,4.26-2.11.24,0,.48.02.72.05,1.85.26,3.42,1.52,4.2,3.35.11.27.24.39.47.48,1.48.53,2.49,1.55,3.03,3.01.1.28.25.42.55.54,1.01.4,1.78,1.03,2.31,1.87.12.18.26.28.54.36,1.67.45,2.86,1.44,3.52,2.95.72,1.64.64,3.29-.25,4.9-.16.28-.18.38-.18.42.02.03.16.1.41.19.72.27,1.52.22,2.21-.14.68-.36,1.17-.99,1.34-1.73.05-.23.08-.46.12-.7.02-.12.03-.24.05-.37l.08-.51.38.35c.87.79,2.07,1.04,3.07.63,1.02-.42,1.64-1.45,1.7-2.81l.02-.56.43.36c1.05.88,2.09,1.07,3.18.58,1.01-.45,1.5-1.3,1.61-2.84Z"/>
|
||||||
|
<path d="m17.3,6.35c-1.11.85-2.22,1.7-3.34,2.54-.6.45-.82.96-.72,1.61.11.65.48,1.06,1.19,1.32,1.75.62,3.4.46,5.06-.5,2.23-1.29,4.52-2.57,6.72-3.81l.74-.42c.44-.24.82-.37,1.18-.37.54,0,1.02.29,1.51.88l17.11,20.77c.07.08.28.34.38.35.06,0,.16-.04.4-.26.7-.64,1.51-.93,2.43-.87.87.05,1.74.03,2.59,0,.73-.02,1.13-.37,1.24-1.1.04-.27.04-.55.04-.79,0-2.26,0-4.52,0-6.77v-4.43c0-2.02,0-4.05,0-6.07,0-1.1-.38-1.47-1.5-1.48h-.13c-.66,0-1.32-.01-1.98,0-1.08,0-2.11-.16-3.1-.54l-2.48-.95c-4.16-1.6-8.33-3.2-12.5-4.77-3.24-1.22-6.29-.79-9.06,1.28-1.94,1.45-3.87,2.92-5.8,4.4Z"/>
|
||||||
|
<path d="m12.13,28.29c-.13-.93-.63-1.55-1.46-1.83-.93-.31-1.75-.08-2.43.69-.51.57-1,1.19-1.47,1.78l-.46.58c-.7.87-.78,1.95-.19,2.82.68,1,1.76,1.4,2.84,1.03.21-.07.44-.13.62,0,.21.14.21.41.22.6.02.79.35,1.47.92,1.92.58.46,1.34.61,2.13.44.21-.05.43-.07.62.05.18.13.24.35.27.57.1.76.48,1.39,1.04,1.78.55.38,1.24.49,1.95.32.13-.03.24-.05.35-.05.3,0,.53.15.69.6.42,1.16,1.68,1.82,2.85,1.51,1.06-.29,2.07-1.74,2.07-3,0-.72-.28-1.34-.8-1.77-.52-.43-1.19-.58-1.89-.45-.55.11-.83-.07-.96-.61-.31-1.38-1.31-2.04-2.74-1.82-.21.03-.48.05-.64-.14-.19-.21-.1-.51-.07-.64.09-.33.1-.68.02-1.03-.13-.62-.48-1.13-.97-1.44-.54-.33-1.22-.41-1.92-.23-.02,0-.04.02-.07.03-.11.05-.38.17-.61-.06-.24-.24-.11-.52-.05-.62.12-.41.2-.72.15-1.04Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
25
frontend/src/_archive/assets/customer-status/shipped.svg
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:currentColor;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M116.713,337.355c-20.655,0-37.456,16.801-37.456,37.456c0,20.655,16.802,37.455,37.456,37.455
|
||||||
|
c20.649,0,37.448-16.8,37.448-37.455C154.161,354.156,137.362,337.355,116.713,337.355z"/>
|
||||||
|
<path class="st0" d="M403.81,337.355c-20.649,0-37.449,16.801-37.449,37.456c0,20.655,16.8,37.455,37.449,37.455
|
||||||
|
c20.649,0,37.45-16.8,37.45-37.455C441.261,354.156,424.459,337.355,403.81,337.355z"/>
|
||||||
|
<path class="st0" d="M497.571,99.735H252.065c-7.974,0-14.429,6.466-14.429,14.44v133.818c0,7.972,6.455,14.428,14.429,14.428
|
||||||
|
h245.506c7.966,0,14.429-6.456,14.429-14.428V114.174C512,106.201,505.538,99.735,497.571,99.735z"/>
|
||||||
|
<path class="st0" d="M499.966,279.409H224.225c-6.64,0-12.079-5.439-12.079-12.079V111.739c0-6.638-5.359-11.999-11.999-11.999
|
||||||
|
H90.554c-3.599,0-6.96,1.602-9.281,4.32L2.801,198.213C1.039,200.373,0,203.094,0,205.893v125.831
|
||||||
|
c0,6.64,5.439,11.999,12.079,11.999h57.516c10.08-15.358,27.438-25.438,47.118-25.438c19.678,0,37.036,10.08,47.116,25.438h192.868
|
||||||
|
c10.079-15.358,27.438-25.438,47.116-25.438c19.678,0,37.039,10.08,47.118,25.438h49.036c6.64,0,11.999-5.359,11.999-11.999
|
||||||
|
v-40.316C511.965,284.768,506.606,279.409,499.966,279.409z M43.997,215.493v-8.32c0-2.881,0.961-5.601,2.72-7.84l50.157-61.675
|
||||||
|
c2.318-2.881,5.839-4.56,9.599-4.56h49.116c6.8,0,12.4,5.519,12.4,12.4v69.995c0,6.798-5.599,12.398-12.4,12.398H56.396
|
||||||
|
C49.516,227.891,43.997,222.292,43.997,215.493z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
10
frontend/src/_archive/assets/customer-status/started-mfg.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 41.05 41.09">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<g>
|
||||||
|
<path d="m0,41.09h41.05V15.4H0v25.69Zm13.42-14.41l3.62,3.66,11.24-11.32,3.66,3.62-11.32,11.24-3.58,3.66-3.62-3.66-3.66-3.62,3.66-3.58Z"/>
|
||||||
|
<polygon points="35.9 0 23.12 0 23.12 10.25 41.05 10.25 35.9 0"/>
|
||||||
|
<polygon points="17.97 0 5.15 0 0 10.25 17.97 10.25 17.97 0"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 495 B |
6
frontend/src/_archive/assets/customer-status/wrench.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44.14 44.14">
|
||||||
|
<g id="Layer_1-2" data-name="Layer 1">
|
||||||
|
<path d="m0,22.07c0-6.09,2.16-11.3,6.49-15.62C10.81,2.12,16.01-.03,22.07,0c6.06.03,11.27,2.18,15.62,6.44,4.35,4.27,6.5,9.48,6.44,15.62-.06,6.15-2.21,11.36-6.44,15.62-4.24,4.27-9.45,6.41-15.62,6.44-6.18.03-11.37-2.12-15.58-6.44C2.28,33.37.12,28.16,0,22.07Zm8.78,8.87l4.41,4.41,12.49-12.54c1.21.47,2.52.56,3.93.26,1.41-.29,2.62-.94,3.62-1.94,1-1,1.65-2.19,1.94-3.58.29-1.38.21-2.69-.26-3.93l-4.41,4.37-3.18-1.19-1.19-3.18,4.37-4.41c-.74-.29-1.53-.44-2.38-.44-2.03,0-3.72.72-5.08,2.16-1.06.97-1.72,2.16-1.99,3.58-.26,1.41-.19,2.72.22,3.93l-12.49,12.49Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 750 B |
2
frontend/src/_archive/assets/global-icons/delete.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.755,20.283,4,8H20L18.245,20.283A2,2,0,0,1,16.265,22H7.735A2,2,0,0,1,5.755,20.283ZM21,4H16V3a1,1,0,0,0-1-1H9A1,1,0,0,0,8,3V4H3A1,1,0,0,0,3,6H21a1,1,0,0,0,0-2Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
6
frontend/src/_archive/assets/global-icons/download.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 7L12 14M12 14L15 11M12 14L9 11" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 17H12H8" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<path d="M22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C21.5093 4.43821 21.8356 5.80655 21.9449 8" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 782 B |
24
frontend/src/_archive/assets/global-icons/edit.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" ?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<title/>
|
||||||
|
|
||||||
|
<g id="Complete">
|
||||||
|
|
||||||
|
<g id="edit">
|
||||||
|
|
||||||
|
<g>
|
||||||
|
|
||||||
|
<path d="M20,16v4a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V6A2,2,0,0,1,4,4H8" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
||||||
|
|
||||||
|
<polygon fill="none" points="12.5 15.8 22 6.2 17.8 2 8.3 11.5 8 16 12.5 15.8" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 594 B |
4
frontend/src/_archive/assets/global-icons/expand.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 8L21 3M21 3H16M21 3V8M8 8L3 3M3 3L3 8M3 3L8 3M8 16L3 21M3 21H8M3 21L3 16M16 16L21 21M21 21V16M21 21H16" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 427 B |
4
frontend/src/_archive/assets/global-icons/nextcloud.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16.027 8.713c-3.333 0-6.136 2.287-6.991 5.355-0.744-1.641-2.391-2.808-4.301-2.808-2.609 0.016-4.724 2.131-4.735 4.74 0.011 2.609 2.125 4.724 4.735 4.74 1.911 0 3.552-1.167 4.301-2.813 0.855 3.073 3.657 5.36 6.991 5.36 3.312 0 6.099-2.26 6.973-5.308 0.755 1.615 2.375 2.761 4.26 2.761 2.615-0.016 4.729-2.131 4.74-4.74-0.011-2.609-2.125-4.724-4.74-4.74-1.885 0-3.505 1.147-4.265 2.761-0.869-3.048-3.656-5.308-6.968-5.308zM16.027 11.495c2.5 0 4.5 2 4.5 4.505s-2 4.505-4.5 4.505c-2.496 0.011-4.516-2.016-4.505-4.505 0-2.505 2-4.505 4.505-4.505zM4.735 14.041c1.099 0 1.959 0.86 1.959 1.959s-0.86 1.959-1.959 1.959c-1.084 0.011-1.969-0.876-1.953-1.959 0-1.099 0.859-1.959 1.953-1.959zM27.26 14.041c1.1 0 1.959 0.86 1.959 1.959s-0.859 1.959-1.959 1.959c-1.083 0.011-1.963-0.876-1.953-1.959 0-1.099 0.86-1.959 1.953-1.959z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
4
frontend/src/_archive/assets/global-icons/refresh.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355C22 19.0711 22 16.714 22 12C22 7.28595 22 4.92893 20.5355 3.46447C19.0711 2 16.714 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447ZM5.46058 11.0833C5.83333 7.79988 8.62406 5.25 12.0096 5.25C13.9916 5.25 15.7702 6.12471 16.9775 7.50653C17.25 7.81846 17.2181 8.29226 16.9061 8.56479C16.5942 8.83733 16.1204 8.80539 15.8479 8.49347C14.9136 7.42409 13.541 6.75 12.0096 6.75C9.45215 6.75 7.33642 8.63219 6.97332 11.0833H7.33654C7.63998 11.0833 7.91353 11.2662 8.02955 11.5466C8.14558 11.8269 8.08122 12.1496 7.86651 12.364L6.69825 13.5307C6.40544 13.8231 5.93113 13.8231 5.63832 13.5307L4.47005 12.364C4.25534 12.1496 4.19099 11.8269 4.30701 11.5466C4.42304 11.2662 4.69658 11.0833 5.00002 11.0833H5.46058ZM17.3018 10.4693C17.5947 10.1769 18.069 10.1769 18.3618 10.4693L19.53 11.636C19.7448 11.8504 19.8091 12.1731 19.6931 12.4534C19.5771 12.7338 19.3035 12.9167 19.0001 12.9167H18.5395C18.1668 16.2001 15.376 18.75 11.9905 18.75C10.0085 18.75 8.22995 17.8753 7.02263 16.4935C6.7501 16.1815 6.78203 15.7077 7.09396 15.4352C7.40589 15.1627 7.87968 15.1946 8.15222 15.5065C9.08654 16.5759 10.4591 17.25 11.9905 17.25C14.548 17.25 16.6637 15.3678 17.0268 12.9167H16.6636C16.3601 12.9167 16.0866 12.7338 15.9705 12.4534C15.8545 12.1731 15.9189 11.8504 16.1336 11.636L17.3018 10.4693Z" fill="#1C274C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
18
frontend/src/_archive/assets/global-icons/reply.svg
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg version="1.1" id="Uploaded to svgrepo.com" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 32 32" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.duotone_twee{fill:#555D5E;}
|
||||||
|
.duotone_een{fill:#0B1719;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="duotone_een" d="M25.5,12h-2c-0.276,0-0.5,0.224-0.5,0.5V22h-7.574v-1.2c0-0.463-0.38-0.8-0.799-0.8
|
||||||
|
c-0.277,0-5.487,2.651-5.758,2.786c-0.589,0.294-0.589,1.134,0,1.428C9.129,24.344,14.338,27,14.627,27
|
||||||
|
c0.419,0,0.799-0.337,0.799-0.799V25h9.824c0.414,0,0.75-0.336,0.75-0.75c0-6.254,0-4.654,0-11.75C26,12.224,25.776,12,25.5,12z"/>
|
||||||
|
<path class="duotone_twee" d="M21.813,9.845l-8.547,6.427c-0.159,0.119-0.373,0.119-0.531,0L4.187,9.845
|
||||||
|
C3.828,9.575,4.113,9,4.562,9h16.875C21.888,9,22.171,9.576,21.813,9.845z M14.627,19c0.992,0,1.799,0.807,1.799,1.8V21h5.012
|
||||||
|
C21.748,21,22,20.736,22,20.411v-9.455c-4.751,3.572-2.949,2.217-8.133,6.116c-0.512,0.385-1.216,0.388-1.733,0
|
||||||
|
C6.951,13.175,8.744,14.523,4,10.956v9.455C4,20.736,4.252,21,4.562,21h5.628C14.165,19.006,14.345,19,14.627,19z"/>
|
||||||
|
</g>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
19
frontend/src/_archive/assets/global-icons/video.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 512 512" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
<![CDATA[
|
||||||
|
.st0{fill:#000000;}
|
||||||
|
]]>
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M482.703,98.813C456.469,77.625,363.953,61,256,61S55.531,77.625,29.297,98.813C5.188,118.25,0,206.125,0,256
|
||||||
|
s5.188,137.75,29.297,157.188C55.531,434.375,148.047,451,256,451s200.469-16.625,226.703-37.813
|
||||||
|
C506.813,393.75,512,305.875,512,256S506.813,118.25,482.703,98.813z M332.813,258.406l-118.844,70.938
|
||||||
|
c-0.875,0.5-1.938,0.531-2.813,0.031s-1.422-1.438-1.422-2.438V256v-70.938c0-1.016,0.547-1.938,1.422-2.438
|
||||||
|
s1.938-0.469,2.813,0.031l118.844,70.938c0.844,0.5,1.359,1.406,1.359,2.406C334.172,256.969,333.656,257.906,332.813,258.406z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
2
frontend/src/_archive/assets/global-icons/waveform.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg"><path d="M 27.9999 51.9063 C 41.0546 51.9063 51.9063 41.0781 51.9063 28 C 51.9063 14.9453 41.0312 4.0937 27.9765 4.0937 C 14.8983 4.0937 4.0937 14.9453 4.0937 28 C 4.0937 41.0781 14.9218 51.9063 27.9999 51.9063 Z M 27.9999 47.9219 C 16.9374 47.9219 8.1014 39.0625 8.1014 28 C 8.1014 16.9609 16.9140 8.0781 27.9765 8.0781 C 39.0155 8.0781 47.8983 16.9609 47.9219 28 C 47.9454 39.0625 39.0390 47.9219 27.9999 47.9219 Z M 25.6327 43.5391 C 26.3593 43.5391 26.8983 42.9766 26.8983 42.2500 L 26.8983 13.75 C 26.8983 13.0234 26.3358 12.4375 25.6327 12.4375 C 24.8827 12.4375 24.3202 13.0234 24.3202 13.75 L 24.3202 42.2500 C 24.3202 43.0000 24.8593 43.5391 25.6327 43.5391 Z M 35.1014 40.1406 C 35.8514 40.1406 36.4140 39.5547 36.4140 38.8516 L 36.4140 17.1250 C 36.4140 16.3984 35.8514 15.8359 35.1014 15.8359 C 34.3983 15.8359 33.8358 16.4219 33.8358 17.1250 L 33.8358 38.8516 C 33.8358 39.5547 34.3983 40.1406 35.1014 40.1406 Z M 20.8749 37.1641 C 21.5780 37.1641 22.1405 36.6016 22.1405 35.8750 L 22.1405 20.0781 C 22.1405 19.3750 21.5780 18.8125 20.8749 18.8125 C 20.1483 18.8125 19.5624 19.3750 19.5624 20.0781 L 19.5624 35.8750 C 19.5624 36.6016 20.1483 37.1641 20.8749 37.1641 Z M 30.3671 35.2890 C 31.0936 35.2890 31.6562 34.75 31.6562 34.0234 L 31.6562 21.9531 C 31.6562 21.2266 31.0936 20.6875 30.3671 20.6875 C 29.6405 20.6875 29.0780 21.25 29.0780 21.9531 L 29.0780 34.0234 C 29.0780 34.7266 29.6405 35.2890 30.3671 35.2890 Z M 39.8827 32.5 C 40.6093 32.5 41.1718 31.9609 41.1718 31.2344 L 41.1718 24.7422 C 41.1718 24.0156 40.6093 23.4766 39.8827 23.4766 C 39.1562 23.4766 38.5936 24.0156 38.5936 24.7422 L 38.5936 31.2344 C 38.5936 31.9609 39.1562 32.5 39.8827 32.5 Z M 16.0936 31.3281 C 16.8202 31.3281 17.4062 30.7656 17.4062 30.0625 L 17.4062 25.9141 C 17.4062 25.2109 16.8202 24.6484 16.0936 24.6484 C 15.3905 24.6484 14.8046 25.2109 14.8046 25.9141 L 14.8046 30.0625 C 14.8046 30.7656 15.3905 31.3281 16.0936 31.3281 Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/src/_archive/assets/logos/cloudflash_large.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
frontend/src/_archive/assets/logos/cloudflash_small.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
17
frontend/src/_archive/assets/other-icons/important.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 299.467 299.467" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M293.588,219.182L195.377,32.308c-8.939-17.009-26.429-27.575-45.644-27.575s-36.704,10.566-45.644,27.575L5.879,219.182
|
||||||
|
c-8.349,15.887-7.77,35.295,1.509,50.647c9.277,15.36,26.189,24.903,44.135,24.903h196.422c17.943,0,34.855-9.542,44.133-24.899
|
||||||
|
C301.357,254.477,301.936,235.069,293.588,219.182z M266.4,254.319c-3.881,6.424-10.953,10.414-18.456,10.414H51.522
|
||||||
|
c-7.505,0-14.576-3.99-18.457-10.417c-3.88-6.419-4.121-14.534-0.63-21.177l98.211-186.876
|
||||||
|
c3.737-7.112,11.052-11.531,19.087-11.531s15.35,4.418,19.087,11.531l98.211,186.876
|
||||||
|
C270.522,239.782,270.281,247.897,266.4,254.319z"/>
|
||||||
|
<polygon points="144.037,201.424 155.429,201.424 166.545,87.288 132.92,87.288 "/>
|
||||||
|
<path d="M149.733,212.021c-8.98,0-16.251,7.272-16.251,16.252c0,8.971,7.271,16.251,16.251,16.251
|
||||||
|
c8.979,0,16.251-7.28,16.251-16.251C165.984,219.294,158.713,212.021,149.733,212.021z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
23
frontend/src/_archive/assets/other-icons/issues.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 283.722 283.722" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M184.721,128.156c4.398-14.805,7.516-29.864,8.885-43.783c0.06-0.607-0.276-1.159-0.835-1.373l-70.484-26.932
|
||||||
|
c-0.152-0.058-0.312-0.088-0.475-0.088c-0.163,0-0.322,0.03-0.474,0.088L50.851,83c-0.551,0.21-0.894,0.775-0.835,1.373
|
||||||
|
c2.922,29.705,13.73,64.62,28.206,91.12c14.162,25.923,30.457,41.4,43.589,41.4c8.439,0,18.183-6.4,27.828-17.846l-16.375-16.375
|
||||||
|
c-14.645-14.645-14.645-38.389,0-53.033C147.396,115.509,169.996,115.017,184.721,128.156z"/>
|
||||||
|
<path d="M121.812,236.893c-46.932,0-85.544-87.976-91.7-150.562c-0.94-9.56,4.627-18.585,13.601-22.013l70.486-26.933
|
||||||
|
c2.451-0.937,5.032-1.405,7.613-1.405c2.581,0,5.162,0.468,7.614,1.405l70.484,26.932c8.987,3.434,14.542,12.439,13.6,22.013
|
||||||
|
c-1.773,18.028-6.244,38.161-12.826,57.693l11.068,11.068l17.865-17.866c6.907-20.991,11.737-42.285,13.845-61.972
|
||||||
|
c1.322-12.347-5.53-24.102-16.934-29.017l-93.512-40.3c-7.152-3.082-15.257-3.082-22.409,0l-93.512,40.3
|
||||||
|
C5.705,51.147-1.159,62.922,0.162,75.255c8.765,81.851,64.476,191.512,121.65,191.512c0.356,0,0.712-0.023,1.068-0.032
|
||||||
|
c-1.932-10.793,0.888-22.262,8.456-31.06C128.205,236.465,125.029,236.893,121.812,236.893z"/>
|
||||||
|
<path d="M240.037,208.125c7.327-7.326,30.419-30.419,37.827-37.827c7.81-7.811,7.81-20.475,0-28.285
|
||||||
|
c-7.811-7.811-20.475-7.811-28.285,0c-7.41,7.41-30.5,30.5-37.827,37.827l-37.827-37.827c-7.81-7.811-20.475-7.811-28.285,0
|
||||||
|
c-7.811,7.811-7.811,20.475,0,28.285l37.827,37.827c-7.326,7.326-30.419,30.419-37.827,37.827c-7.811,7.811-7.811,20.475,0,28.285
|
||||||
|
c7.809,7.809,20.474,7.811,28.285,0c7.41-7.41,30.5-30.499,37.827-37.827l37.827,37.827c7.809,7.809,20.474,7.811,28.285,0
|
||||||
|
c7.81-7.81,7.81-20.475,0-28.285L240.037,208.125z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 -8 72 72" id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#030104;}</style></defs><title>handshake</title><path class="cls-1" d="M64,12.78v17s-3.63.71-4.38.81-3.08.85-4.78-.78C52.22,27.25,42.93,18,42.93,18a3.54,3.54,0,0,0-4.18-.21c-2.36,1.24-5.87,3.07-7.33,3.78a3.37,3.37,0,0,1-5.06-2.64,3.44,3.44,0,0,1,2.1-3c3.33-2,10.36-6,13.29-7.52,1.78-1,3.06-1,5.51,1C50.27,12,53,14.27,53,14.27a2.75,2.75,0,0,0,2.26.43C58.63,14,64,12.78,64,12.78ZM27,41.5a3,3,0,0,0-3.55-4.09,3.07,3.07,0,0,0-.64-3,3.13,3.13,0,0,0-3-.75,3.07,3.07,0,0,0-.65-3,3.38,3.38,0,0,0-4.72.13c-1.38,1.32-2.27,3.72-1,5.14s2.64.55,3.72.3c-.3,1.07-1.2,2.07-.09,3.47s2.64.55,3.72.3c-.3,1.07-1.16,2.16-.1,3.46s2.84.61,4,.25c-.45,1.15-1.41,2.39-.18,3.79s4.08.75,5.47-.58a3.32,3.32,0,0,0,.3-4.68A3.18,3.18,0,0,0,27,41.5Zm25.35-8.82L41.62,22a3.53,3.53,0,0,0-3.77-.68c-1.5.66-3.43,1.56-4.89,2.24a8.15,8.15,0,0,1-3.29,1.1,5.59,5.59,0,0,1-3-10.34C29,12.73,34.09,10,34.09,10a6.46,6.46,0,0,0-5-2C25.67,8,18.51,12.7,18.51,12.7a5.61,5.61,0,0,1-4.93.13L8,10.89v19.4s1.59.46,3,1a6.33,6.33,0,0,1,1.56-2.47,6.17,6.17,0,0,1,8.48-.06,5.4,5.4,0,0,1,1.34,2.37,5.49,5.49,0,0,1,2.29,1.4A5.4,5.4,0,0,1,26,34.94a5.47,5.47,0,0,1,3.71,4,5.38,5.38,0,0,1,2.39,1.43,5.65,5.65,0,0,1,1.48,4.89,0,0,0,0,1,0,0s.8.9,1.29,1.39a2.46,2.46,0,0,0,3.48-3.48s2,2.48,4.28,1c2-1.4,1.69-3.06.74-4a3.19,3.19,0,0,0,4.77.13,2.45,2.45,0,0,0,.13-3.3s1.33,1.81,4,.12c1.89-1.6,1-3.43,0-4.39Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="currentColor" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>logs</title>
|
||||||
|
<path d="M0 24q0 0.832 0.576 1.44t1.44 0.576h1.984q0 2.496 1.76 4.224t4.256 1.76h6.688q-2.144-1.504-3.456-4h-3.232q-0.832 0-1.44-0.576t-0.576-1.408v-20q0-0.832 0.576-1.408t1.44-0.608h16q0.8 0 1.408 0.608t0.576 1.408v7.232q2.496 1.312 4 3.456v-10.688q0-2.496-1.76-4.256t-4.224-1.76h-16q-2.496 0-4.256 1.76t-1.76 4.256h-1.984q-0.832 0-1.44 0.576t-0.576 1.408 0.576 1.44 1.44 0.576h1.984v4h-1.984q-0.832 0-1.44 0.576t-0.576 1.408 0.576 1.44 1.44 0.576h1.984v4h-1.984q-0.832 0-1.44 0.576t-0.576 1.408zM10.016 24h2.080q0-0.064-0.032-0.416t-0.064-0.576 0.064-0.544 0.032-0.448h-2.080v1.984zM10.016 20h2.464q0.288-1.088 0.768-1.984h-3.232v1.984zM10.016 16h4.576q0.992-1.216 2.112-1.984h-6.688v1.984zM10.016 12h16v-1.984h-16v1.984zM10.016 8h16v-1.984h-16v1.984zM14.016 23.008q0 1.824 0.704 3.488t1.92 2.88 2.88 1.92 3.488 0.704 3.488-0.704 2.88-1.92 1.92-2.88 0.704-3.488-0.704-3.488-1.92-2.88-2.88-1.92-3.488-0.704-3.488 0.704-2.88 1.92-1.92 2.88-0.704 3.488zM18.016 23.008q0-2.080 1.44-3.52t3.552-1.472 3.52 1.472 1.472 3.52q0 2.080-1.472 3.52t-3.52 1.472-3.552-1.472-1.44-3.52zM22.016 23.008q0 0.416 0.288 0.704t0.704 0.288h1.984q0.416 0 0.704-0.288t0.32-0.704-0.32-0.704-0.704-0.288h-0.992v-0.992q0-0.416-0.288-0.704t-0.704-0.32-0.704 0.32-0.288 0.704v1.984z"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
2
frontend/src/_archive/assets/side-menu-icons/api.svg
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="currentColor"><path d="M21.3,19a2.42,2.42,0,0,1-2.5.56l-2.35,2.35a.34.34,0,0,1-.49,0l-1-1a.36.36,0,0,1,0-.49l2.36-2.35a2.39,2.39,0,0,1,3.39-2.91L19.12,16.8l1,1,1.62-1.62A2.39,2.39,0,0,1,21.3,19ZM22,8v5.76A4.47,4.47,0,0,0,19.5,13a4.57,4.57,0,0,0-1.29.19V9.29H16.66V14A4.5,4.5,0,0,0,15,17.5a4.07,4.07,0,0,0,0,.5H4a2,2,0,0,1-2-2V8A2,2,0,0,1,4,6H20A2,2,0,0,1,22,8ZM11,15,9.09,9.27H7L5.17,15h1.7l.29-1.07H9L9.29,15Zm4.77-3.89a1.67,1.67,0,0,0-.55-1.35,2.43,2.43,0,0,0-1.62-.47h-2V15h1.54V13.11h.44a2.75,2.75,0,0,0,1-.17,1.82,1.82,0,0,0,.67-.44,1.63,1.63,0,0,0,.36-.64A2.36,2.36,0,0,0,15.75,11.11Zm-7.3.62-.12-.44-.15-.58c0-.21-.08-.37-.11-.5a4.63,4.63,0,0,1-.1.48c0,.19-.08.38-.13.57s-.08.34-.12.47l-.24.93H8.69Zm5.59-1a.63.63,0,0,0-.5-.17h-.4v1.31h.31a.9.9,0,0,0,.37-.07.59.59,0,0,0,.27-.22.75.75,0,0,0,.11-.42A.57.57,0,0,0,14,10.71Z"/><rect width="24" height="24" fill="none"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
28
frontend/src/_archive/assets/side-menu-icons/app-users.svg
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="currentColor" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px"
|
||||||
|
height="800px" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve">
|
||||||
|
|
||||||
|
<g id="b75708d097f2188dff6617b0f00f7c43">
|
||||||
|
|
||||||
|
<path display="inline" d="M120.606,169h270.788v220.663c0,13.109-10.628,23.737-23.721,23.737h-27.123v67.203
|
||||||
|
c0,17.066-13.612,30.897-30.415,30.897c-16.846,0-30.438-13.831-30.438-30.897v-67.203h-47.371v67.203
|
||||||
|
c0,17.066-13.639,30.897-30.441,30.897c-16.799,0-30.437-13.831-30.437-30.897v-67.203h-27.099
|
||||||
|
c-13.096,0-23.744-10.628-23.744-23.737V169z M67.541,167.199c-16.974,0-30.723,13.963-30.723,31.2v121.937
|
||||||
|
c0,17.217,13.749,31.204,30.723,31.204c16.977,0,30.723-13.987,30.723-31.204V198.399
|
||||||
|
C98.264,181.162,84.518,167.199,67.541,167.199z M391.395,146.764H120.606c3.342-38.578,28.367-71.776,64.392-90.998
|
||||||
|
l-25.746-37.804c-3.472-5.098-2.162-12.054,2.946-15.525c5.102-3.471,12.044-2.151,15.533,2.943l28.061,41.232
|
||||||
|
c15.558-5.38,32.446-8.469,50.208-8.469c17.783,0,34.672,3.089,50.229,8.476L334.29,5.395c3.446-5.108,10.41-6.428,15.512-2.957
|
||||||
|
c5.108,3.471,6.418,10.427,2.946,15.525l-25.725,37.804C363.047,74.977,388.055,108.175,391.395,146.764z M213.865,94.345
|
||||||
|
c0-8.273-6.699-14.983-14.969-14.983c-8.291,0-14.99,6.71-14.99,14.983c0,8.269,6.721,14.976,14.99,14.976
|
||||||
|
S213.865,102.614,213.865,94.345z M329.992,94.345c0-8.273-6.722-14.983-14.99-14.983c-8.291,0-14.97,6.71-14.97,14.983
|
||||||
|
c0,8.269,6.679,14.976,14.97,14.976C323.271,109.321,329.992,102.614,329.992,94.345z M444.48,167.156
|
||||||
|
c-16.956,0-30.744,13.984-30.744,31.222v121.98c0,17.238,13.788,31.226,30.744,31.226c16.978,0,30.701-13.987,30.701-31.226
|
||||||
|
v-121.98C475.182,181.14,461.458,167.156,444.48,167.156z">
|
||||||
|
|
||||||
|
</path>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
17
frontend/src/_archive/assets/side-menu-icons/archetypes.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M10 4H6v6h4V4z" />
|
||||||
|
<path d="M18 14h-4v6h4v-6z" />
|
||||||
|
<path d="M14 4h2v6m-2 0h4" />
|
||||||
|
<path d="M6 14h2v6m-2 0h4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 454 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5.5C11.4477 5.5 11 5.94772 11 6.5V12C11 12.5523 11.4477 13 12 13C12.5523 13 13 12.5523 13 12V6.5C13 5.94772 12.5523 5.5 12 5.5Z" fill="currentColor"/>
|
||||||
|
<path d="M12 17.5C12.8284 17.5 13.5 16.8284 13.5 16C13.5 15.1716 12.8284 14.5 12 14.5C11.1716 14.5 10.5 15.1716 10.5 16C10.5 16.8284 11.1716 17.5 12 17.5Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.00195C11.0268 1.00195 10.3021 1.39456 9.68627 1.72824C9.54287 1.80594 9.40536 1.88044 9.27198 1.94605C8.49696 2.32729 7.32256 2.78014 4.93538 2.94144C3.36833 3.04732 1.97417 4.32298 2.03666 6.03782C2.13944 8.85853 2.46666 11.7444 3.12474 14.1763C3.76867 16.5559 4.78826 18.7274 6.44528 19.8321C8.02992 20.8885 9.33329 21.8042 10.2053 22.4293C11.276 23.1969 12.724 23.1969 13.7947 22.4293C14.6667 21.8042 15.97 20.8885 17.5547 19.8321C19.2117 18.7274 20.2313 16.5559 20.8752 14.1763C21.5333 11.7445 21.8605 8.8586 21.9633 6.03782C22.0258 4.32298 20.6316 3.04732 19.0646 2.94144C16.6774 2.78014 15.503 2.32729 14.728 1.94605C14.5946 1.88045 14.4571 1.80596 14.3138 1.72828C13.6979 1.39459 12.9732 1.00195 12 1.00195ZM5.07021 4.93689C7.70274 4.75901 9.13306 4.24326 10.1548 3.74068C10.4467 3.5971 10.6724 3.47746 10.8577 3.37923C11.3647 3.11045 11.5694 3.00195 12 3.00195C12.4305 3.00195 12.6352 3.11045 13.1423 3.37923C13.3276 3.47746 13.5533 3.5971 13.8452 3.74068C14.8669 4.24326 16.2972 4.75901 18.9298 4.93689C19.5668 4.97993 19.9826 5.47217 19.9646 5.965C19.865 8.70066 19.5487 11.4218 18.9447 13.6539C18.3265 15.9383 17.4653 17.4879 16.4453 18.1679C14.8385 19.2392 13.5162 20.1681 12.6294 20.8038C12.2553 21.072 11.7447 21.072 11.3705 20.8038C10.4837 20.1681 9.1615 19.2392 7.55469 18.1679C6.53465 17.4879 5.67349 15.9383 5.0553 13.6538C4.45127 11.4217 4.13502 8.70059 4.03533 5.965C4.01738 5.47217 4.43314 4.97993 5.07021 4.93689Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M0 24q0 0.832 0.576 1.44t1.44 0.576h1.984q0 2.496 1.76 4.224t4.256 1.76h6.688q-2.144-1.504-3.456-4h-3.232q-0.832 0-1.44-0.576t-0.576-1.408v-20q0-0.832 0.576-1.408t1.44-0.608h16q0.8 0 1.408 0.608t0.576 1.408v7.232q2.496 1.312 4 3.456v-10.688q0-2.496-1.76-4.256t-4.224-1.76h-16q-2.496 0-4.256 1.76t-1.76 4.256h-1.984q-0.832 0-1.44 0.576t-0.576 1.408 0.576 1.44 1.44 0.576h1.984v4h-1.984q-0.832 0-1.44 0.576t-0.576 1.408 0.576 1.44 1.44 0.576h1.984v4h-1.984q-0.832 0-1.44 0.576t-0.576 1.408zM10.016 24h2.080q0-0.064-0.032-0.416t-0.064-0.576 0.064-0.544 0.032-0.448h-2.080v1.984zM10.016 20h2.464q0.288-1.088 0.768-1.984h-3.232v1.984zM10.016 16h4.576q0.992-1.216 2.112-1.984h-6.688v1.984zM10.016 12h16v-1.984h-16v1.984zM10.016 8h16v-1.984h-16v1.984zM14.016 23.008q0 1.824 0.704 3.488t1.92 2.88 2.88 1.92 3.488 0.704 3.488-0.704 2.88-1.92 1.92-2.88 0.704-3.488-0.704-3.488-1.92-2.88-2.88-1.92-3.488-0.704-3.488 0.704-2.88 1.92-1.92 2.88-0.704 3.488zM18.016 23.008q0-2.080 1.44-3.52t3.552-1.472 3.52 1.472 1.472 3.52q0 2.080-1.472 3.52t-3.52 1.472-3.552-1.472-1.44-3.52zM22.016 23.008q0 0.416 0.288 0.704t0.704 0.288h1.984q0.416 0 0.704-0.288t0.32-0.704-0.32-0.704-0.704-0.288h-0.992v-0.992q0-0.416-0.288-0.704t-0.704-0.32-0.704 0.32-0.288 0.704v1.984z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |